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

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. 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:

(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 (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 (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, 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 (lines 619–632), shows how to pass a widget function directly to the model:

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 (lines 822–828), you can create a reusable form class with a custom widget property:

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:

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 – 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 – Provides unit tests demonstrating widget injection patterns, particularly lines 619–632 which validate the widget= argument approach.

  • examples/src/forms__AirForm__widget.py – Runnable example showing dynamic widget assignment to form instances.

  • 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 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, 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →