# How to Create Custom Form Widgets in Air: A Complete Guide

> Learn how to create custom form widgets in Air with this complete guide. Explore options like Model.to_form(), subclassing AirForm, or setting the widget attribute for tailored HTML output.

- Repository: [Feldroy/air](https://github.com/feldroy/air)
- Tags: how-to-guide
- Published: 2026-03-01

---

**Air renders HTML forms from Pydantic models using callable widgets that return HTML strings or `air.Raw` objects, and you can customize form output by providing your own widget function via `Model.to_form()`, subclassing `AirForm`, or setting the `widget` attribute directly.**

Air is a Python web framework that generates HTML forms directly from Pydantic models using a widget-based architecture. When you need to create custom form widgets in Air to modify the generated markup, inject CSS classes, or add JavaScript hooks, you can override the default rendering pipeline while preserving the framework's automatic field generation capabilities.

## Understanding the Widget Architecture in Air

Air delegates all HTML form generation to a **widget callable** defined in [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py). By default, the framework uses `default_form_widget` (defined at line 441), which accepts a Pydantic model class and returns the standard HTML form markup.

A custom widget must implement the signature:

```python
(model: Type[BaseModel], data: dict | None, errors: dict | None, includes: Iterable[str] | None)

```

The callable must return either a plain HTML string or an `air.Raw` node to prevent additional escaping. Inside your custom widget, you can call `default_form_widget` to obtain the base markup and then wrap, filter, or extend it.

## Three Methods to Create Custom Form Widgets in Air

Air provides three equivalent injection points for custom widgets, allowing you to choose the approach that best fits your application structure.

### Method 1: Passing a Widget to `Model.to_form()`

The most direct way to create custom form widgets in Air is passing a callable to the `widget` parameter of `to_form()`. This approach is ideal for one-off customizations where you do not want to create a permanent subclass.

According to the test suite in [`tests/test_forms.py`](https://github.com/feldroy/air/blob/main/tests/test_forms.py) (lines 619–632), this pattern validates that the custom callable receives the correct arguments and returns the expected HTML structure.

### Method 2: Overriding the Widget Property on an `AirForm` Subclass

For reusable form components, subclass `AirForm` and override the `widget` property. As implemented in [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py) (lines 822–828), the property injection logic ensures that your custom callable replaces the default renderer for all instances of that form class.

When using this method, the callable receives `self` (the form instance) as the first argument, followed by the standard four parameters.

### Method 3: Dynamically Assigning a Widget to an Instance

You can also modify the widget after instantiation by setting the `widget` attribute directly on an `AirForm` instance. This pattern, demonstrated in [`examples/src/forms__AirForm__widget.py`](https://github.com/feldroy/air/blob/main/examples/src/forms__AirForm__widget.py), allows for dynamic alterations based on runtime conditions, such as user roles or feature flags.

## Step-by-Step Implementation Guide

To create custom form widgets in Air that integrate cleanly with the framework's validation and error handling, follow these four steps:

1. **Define the widget function** – Accept the four standard arguments (or `self` plus the four if implementing as a method). Import `default_form_widget` from `air.forms` to access the base renderer.

2. **Generate base form markup** – Call `default_form_widget(model, data, errors, includes)` to retrieve the standard HTML. This preserves Air's automatic field generation and error display logic.

3. **Inject custom markup** – Wrap the base HTML in container elements, add CSS classes for styling frameworks like Tailwind or Bootstrap, insert data attributes for JavaScript hooks, or filter specific fields using the `includes` parameter.

4. **Return the result** – Output either a plain string or an `air.Raw` object. Using `air.Raw` ensures Air does not escape your HTML entities during final rendering.

## Practical Code Examples

The following examples demonstrate the three injection methods using the actual patterns found in the Air repository.

### Example 1: Custom Widget via `to_form()`

This pattern, validated in [`tests/test_forms.py`](https://github.com/feldroy/air/blob/main/tests/test_forms.py) (lines 619–632), shows how to pass a widget function directly to the model:

```python
from air.forms import default_form_widget
import air

def custom_widget(model, data=None, errors=None, includes=None):
    # Re‑use the standard widget and then wrap it

    base_html = default_form_widget(model, data, errors, includes)
    return air.Raw(f"""
        <section class="my‑custom‑form">
            <h2>Contact us</h2>
            {base_html}
        </section>
    """)

class ContactModel(air.AirModel):
    name: str
    email: str
    message: str

# Render the form with the custom widget

html = ContactModel.to_form(widget=custom_widget)

```

### Example 2: Subclassing `AirForm` with Property Override

As implemented in [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py) (lines 822–828), you can create a reusable form class with a custom widget property:

```python
import air
from air.forms import default_form_widget

class ContactForm(air.AirForm):
    @property
    def widget(self):
        # ``self`` is the AirForm instance, the remaining args are the same as above

        def wrapper(model, data=None, errors=None, includes=None):
            base = default_form_widget(model, data, errors, includes)
            return air.Raw(f"<div class='wrapper'>{base}</div>")
        return wrapper

class ContactModel(air.AirModel):
    name: str
    email: str
    message: str

# The model automatically picks up ContactForm.widget

html = ContactModel.to_form()

```

### Example 3: Dynamic Widget Assignment

For runtime customization, assign a widget to an existing instance as shown in [`examples/src/forms__AirForm__widget.py`](https://github.com/feldroy/air/blob/main/examples/src/forms__AirForm__widget.py):

```python
import air
from air.forms import default_form_widget

def fancy_widget(model, data=None, errors=None, includes=None):
    return air.Raw(f"<form class='fancy'>{default_form_widget(model, data, errors, includes)}</form>")

class SimpleModel(air.AirModel):
    title: str

# Build the form first

form = SimpleModel.to_form()

# Attach a new widget

form.widget = fancy_widget
html = form()

```

## Key Source Files and Implementation Details

When you create custom form widgets in Air, you will work primarily with these source files:

- **[`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py)** – Contains the `default_form_widget` function (line 441) that generates standard HTML markup, and the property injection logic (lines 822–828) that binds widgets to `AirForm` instances.

- **[`tests/test_forms.py`](https://github.com/feldroy/air/blob/main/tests/test_forms.py)** – Provides unit tests demonstrating widget injection patterns, particularly lines 619–632 which validate the `widget=` argument approach.

- **[`examples/src/forms__AirForm__widget.py`](https://github.com/feldroy/air/blob/main/examples/src/forms__AirForm__widget.py)** – Runnable example showing dynamic widget assignment to form instances.

- **[`examples/src/models__AirModel__to_form.py`](https://github.com/feldroy/air/blob/main/examples/src/models__AirModel__to_form.py)** – Example demonstrating the `to_form()` method with custom widget arguments.

## Summary

To create custom form widgets in Air, remember these key points:

- A **widget** is any callable that accepts a Pydantic model class, optional data, errors, and field includes, then returns HTML or an `air.Raw` object.
- You can inject custom widgets through three mechanisms: passing `widget=` to `Model.to_form()`, overriding the `widget` property on an `AirForm` subclass, or dynamically setting the attribute on an instance.
- Always leverage `default_form_widget` from [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py) as your base renderer to maintain Air's built-in field generation and error handling.
- Return `air.Raw` when your widget constructs HTML manually to prevent the framework from escaping your markup.

## Frequently Asked Questions

### How do I prevent Air from escaping my custom HTML in a widget?

Return an `air.Raw` object instead of a plain string. When your widget function returns `air.Raw(html_string)`, Air treats the content as safe HTML and does not apply additional escaping during the final rendering phase.

### Can I use the same custom widget for multiple Pydantic models?

Yes. Because the widget callable receives the `model` class as its first argument, you can define the function once and reuse it across any Pydantic model. Pass it to different models via `Model.to_form(widget=your_function)` or assign it to a shared `AirForm` subclass used by those models.

### What arguments does a custom widget function receive?

According to the implementation in [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py), your widget must accept four parameters: `model` (the Pydantic model class), `data` (optional dictionary of current values), `errors` (optional validation errors), and `includes` (iterable of field names to render). When overriding the `widget` property on an `AirForm` subclass, the callable receives `self` as the first argument followed by these four parameters.

### How do I modify only part of the form HTML without rewriting everything?

Call `default_form_widget` inside your custom widget to retrieve the base HTML string, then use standard Python string manipulation or templating to inject your modifications. For example, wrap the base HTML in a container div, add CSS classes to specific field labels, or insert JavaScript event handlers before returning the final markup as an `air.Raw` object. This approach preserves Air's automatic field generation while allowing targeted customization.