Form

Rust/UI components for building accessible forms with labels, descriptions, and error messages.

input

Choose a unique username for your account.

Must be at least 8 characters long.

use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use validator::Validate;

use crate::components::hooks::use_form::use_form;
use crate::components::ui::form::{
    Form, FormDescription, FormField, FormGroup, FormInput, FormLabel, FormProvider, FormSet,
};

#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
struct FormData {
    username: String,
    password: String,
}

#[component]
pub fn DemoForm() -> impl IntoView {
    let form = use_form::<FormData>();

    view! {
        <FormProvider form=form>
            <Form class="max-w-md">
                <FormSet>
                    <FormGroup>
                        <FormField field="username">
                            <FormLabel>Username</FormLabel>
                            <FormInput attr:placeholder="Max Wells" />
                            <FormDescription>Choose a unique username for your account.</FormDescription>
                        </FormField>
                        <FormField field="password">
                            <FormLabel>Password</FormLabel>
                            <FormDescription>Must be at least 8 characters long.</FormDescription>
                            <FormInput attr:r#type="password" attr:placeholder="********" />
                        </FormField>
                    </FormGroup>
                </FormSet>
            </Form>
        </FormProvider>
    }
}

Installation

You can run either of the following commands:

# cargo install ui-cli --force
ui add demo_form
ui add form

Update the imports to match your project setup.

Copy and paste the following code into your project:

components/ui/form.rs

use leptos::prelude::*;
use leptos_ui::{clx, variants};
use serde::{Deserialize, Serialize};
use strum::Display;
use tw_merge::tw_merge;
use validator::Validate;

use crate::registry::hooks::use_form::{FieldContext, Form as FormHook, FormContext};
use crate::registry::ui::input::Input;
use crate::registry::ui::label::Label;
use crate::registry::ui::separator::Separator;

mod components {
    use super::*;

    clx! {FormSet, fieldset, "flex flex-col gap-6 has-[>[data-name=CheckboxGroup]]:gap-3 has-[>[data-name=RadioGroup]]:gap-3"}
    clx! {FormGroup, div, "group/field-group @container/field-group flex flex-col gap-7 w-full data-[name=CheckboxGroup]:gap-3 [&>[data-name=FormGroup]]:gap-4"}
    clx! {FormContent, div, "group/field-content flex flex-1 flex-col gap-1.5 leading-snug"}
    clx! {FormTitle, div, "flex items-center gap-2 text-sm leading-snug font-medium w-fit group-data-[disabled=true]/field:opacity-50"}
    clx! {FormDescription, p, "text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5 [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4"}
}

pub use components::*;

/* ========================================================== */
/*                     ✨ FUNCTIONS ✨                        */
/* ========================================================== */

#[component]
pub fn FormProvider<T>(form: FormHook<T>, children: Children) -> impl IntoView
where
    T: Validate + Clone + Default + Serialize + for<'de> Deserialize<'de> + Send + Sync + 'static,
{
    use crate::registry::hooks::use_form::{FormContext, SetValueFn};

    let set_value_fn: SetValueFn = Box::new(move |field: &str, value: String| {
        form.set_value(field, value);
    });

    let ctx = FormContext { values: form.values, errors: form.errors, set_value: StoredValue::new(set_value_fn) };

    provide_context(ctx);
    children()
}

#[component]
pub fn Form(#[prop(into, optional)] class: String, children: Children) -> impl IntoView {
    let _ctx = expect_context::<FormContext>();

    let merged_class = tw_merge!("w-full", class);

    view! { <form class=merged_class>{children()}</form> }
}

/* ========================================================== */
/*                     ✨ FUNCTIONS ✨                        */
/* ========================================================== */

#[derive(Clone, Copy, PartialEq, Eq, Display)]
pub enum FormLegendVariant {
    Legend,
    Label,
}

#[component]
pub fn FormLegend(
    #[prop(into, optional)] class: String,
    #[prop(default = FormLegendVariant::Legend)] variant: FormLegendVariant,
    children: Children,
) -> impl IntoView {
    let merged_class =
        tw_merge!("mb-3 font-medium data-[variant=Legend]:text-base data-[variant=Label]:text-sm", class);

    view! {
        <legend data-name="FormLegend" attr:data-variant=variant.to_string() class=merged_class>
            {children()}
        </legend>
    }
}

variants! {
    FormFieldWrapper {
        base: "group/field flex gap-3 w-full data-[invalid=true]:text-destructive",
        variants: {
            variant: {
                Vertical: "flex-col [&>*]:w-full [&>.sr-only]:w-auto",
                Horizontal: "flex-row items-center [&>[data-name=FieldLabel]]:flex-auto has-[>[data-name=FormContent]]:items-start has-[>[data-name=FormContent]]:[&>[role=checkbox],[role=radio]]:mt-px",
                Responsive: "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto @md/field-group:[&>[data-name=FieldLabel]]:flex-auto @md/field-group:has-[>[data-name=FormContent]]:items-start @md/field-group:has-[>[data-name=FormContent]]:[&>[role=checkbox],[role=radio]]:mt-px",
            },
            size: {
                Default: "",
            }
        },
        component: {
            element: div
        }
    }
}

#[component]
pub fn FormLabel(
    #[prop(into, optional)] class: String,
    #[prop(into, optional)] html_for: String,
    children: Children,
) -> impl IntoView {
    let field_name = if html_for.is_empty() {
        use_context::<FieldContext>().map(|ctx| ctx.name).unwrap_or_default()
    } else {
        html_for
    };

    let merged_class = tw_merge!(
        "group/form-label peer/form-label flex gap-2 leading-snug w-fit group-data-[disabled=true]/field:opacity-50 has-[>[data-name=Field]]:w-full has-[>[data-name=Field]]:flex-col has-[>[data-name=Field]]:rounded-md has-[>[data-name=Field]]:border [&>*]:data-[name=Field]:p-4 has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
        class
    );

    view! {
        <Label attr:data-name="FormLabel" class=merged_class html_for=field_name>
            {children()}
        </Label>
    }
}

#[component]
pub fn FormSeparator(
    #[prop(into, optional)] class: String,
    #[prop(optional)] children: Option<Children>,
) -> impl IntoView {
    let has_content = children.is_some();

    let merged_class = tw_merge!("relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", class);

    view! {
        <div attr:data-name="FormSeparator" attr:data-content=has_content.to_string() class=merged_class>
            <Separator class="absolute inset-0 top-1/2" />
            {children
                .map(|children| {
                    view! {
                        <span
                            class="block relative px-2 mx-auto bg-background text-muted-foreground w-fit"
                            attr:data-name="FormSeparatorContent"
                        >
                            {children()}
                        </span>
                    }
                })}
        </div>
    }
}

#[component]
pub fn FormError(
    #[prop(into, optional)] class: String,
    #[prop(optional)] children: Option<Children>,
    #[prop(optional)] errors: Option<Vec<String>>,
) -> impl IntoView {
    // If children is provided, render it directly
    if let Some(children) = children {
        return view! {
            <div
                role="alert"
                attr:data-name="FormError"
                class=tw_merge!("text-destructive text-sm font-normal", &class)
            >
                {children()}
            </div>
        }
        .into_any();
    }

    // If errors provided, handle them reactively
    if errors.is_some() {
        return view! {
            {move || {
                errors
                    .as_ref()
                    .and_then(|errors| {
                        if errors.is_empty() {
                            None
                        } else if errors.len() == 1 {
                            Some(
                                view! {
                                    <div
                                        role="alert"
                                        attr:data-name="FormError"
                                        class=tw_merge!("text-destructive text-sm font-normal", &class)
                                    >
                                        <span>{errors[0].clone()}</span>
                                    </div>
                                },
                            )
                        } else {
                            Some(
                                view! {
                                    <div
                                        role="alert"
                                        attr:data-name="FormError"
                                        class=tw_merge!("text-destructive text-sm font-normal", &class)
                                    >
                                        <ul class="flex flex-col gap-1 ml-4 list-disc">
                                            {errors
                                                .iter()
                                                .map(|error| view! { <li>{error.clone()}</li> })
                                                .collect::<Vec<_>>()}
                                        </ul>
                                    </div>
                                },
                            )
                        }
                    })
            }}
        }
        .into_any();
    }

    // Otherwise, try to get error from field context
    let field_ctx = use_context::<FieldContext>();
    let form_ctx = use_context::<FormContext>();

    if let (Some(field_ctx), Some(form_ctx)) = (field_ctx, form_ctx) {
        let field_name = field_ctx.name;
        return view! {
            {move || {
                form_ctx
                    .errors
                    .get()
                    .get(&field_name)
                    .and_then(|e| e.clone())
                    .map(|err| {
                        view! {
                            <div
                                role="alert"
                                attr:data-name="FormError"
                                class=tw_merge!("text-destructive text-sm font-normal", &class)
                            >
                                <span>{err}</span>
                            </div>
                        }
                    })
            }}
        }
        .into_any();
    }

    // No error to display
    ().into_any()
}

/* ========================================================== */
/*                  ✨ FORM COMPONENTS ✨                     */
/* ========================================================== */

#[component]
pub fn FormField(#[prop(into)] field: String, children: Children) -> impl IntoView {
    provide_context(FieldContext { name: field.clone() });

    let ctx = expect_context::<FormContext>();
    let has_error = move || ctx.errors.get().get(&field).is_some_and(|e| e.is_some()).then_some("true");

    view! {
        <FormFieldWrapper attr:data-name="FormField" attr:data-invalid=has_error>
            {children()}
        </FormFieldWrapper>
    }
}

#[component]
pub fn FormInput() -> impl IntoView {
    let field_name = expect_context::<FieldContext>().name;
    let form_ctx = expect_context::<FormContext>();

    view! {
        <Input
            attr:id=field_name.clone()
            attr:aria-invalid={
                let field_name = field_name.clone();
                move || { form_ctx.errors.get().get(&field_name).is_some_and(|e| e.is_some()).then_some("true") }
            }
            prop:value={
                let field_name = field_name.clone();
                move || form_ctx.values.get().get(&field_name).cloned().unwrap_or_default()
            }
            on:input=move |ev| {
                form_ctx.set_value.with_value(|f| f(&field_name, event_target_value(&ev)));
            }
        />
    }
}

Update the imports to match your project setup.

Usage

You can use the Form component in combination with the Select component.

use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use validator::Validate;

use crate::components::ui::form::{
    Form,
    FormDescription,
    FormError,
    FormField,
    FormGroup,
    FormInput,
    FormLabel,
    FormProvider,
    FormSet,
};
use crate::components::hooks::use_form::use_form;
#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
struct FormData {
    name: String,
    username: String,
}

#[component]
pub fn MyForm() -> impl IntoView {
    let form = use_form::<FormData>();

    view! {
        <FormProvider form=form>
            <Form class="max-w-md">
                <FormSet>
                    <FormGroup>
                        <FormField field="name">
                            <FormLabel>Full name</FormLabel>
                            <FormInput attr:placeholder="Max Wells" />
                            <FormDescription>This appears on invoices and emails.</FormDescription>
                        </FormField>
                        <FormField field="username">
                            <FormLabel>Username</FormLabel>
                            <FormInput attr:placeholder="maxwells" />
                            <FormError />
                        </FormField>
                    </FormGroup>
                </FormSet>
            </Form>
        </FormProvider>
    }
}

Components

The Form component is composed of several subcomponents:

  • FormProvider: Provides form context for validation and state management
  • Form: Main form wrapper component
  • FormSet: Wraps a group of related fields (fieldset element)
  • FormGroup: Groups multiple fields together
  • FormField: Individual field container with validation context
  • FormInput: Integrated input component with form state binding
  • FormLabel: Label for the field
  • FormDescription: Helper text for the field
  • FormError: Error message display with validation support
  • FormContent: Content wrapper for field elements
  • FormTitle: Title text for fields
  • FormLegend: Legend for fieldsets
  • FormSeparator: Visual separator between fields

Examples

Error Display

Form component with validation error messages displayed inline below fields. This example demonstrates how to show field-level errors in Leptos using Input fields with FormError, automatic error state management, and ARIA attributes for accessibility in Rust applications.

use leptos::prelude::*;

use crate::components::ui::form::{FormError, FormFieldWrapper, FormLabel};
use crate::components::ui::input::Input;

#[component]
pub fn DemoFormError() -> impl IntoView {
    view! {
        <div class="w-full max-w-md">
            <FormFieldWrapper attr:data-invalid="true">
                <FormLabel html_for="email">Email</FormLabel>
                <Input attr:id="email" attr:r#type="email" attr:aria-invalid="true" />
                <FormError>Enter a valid email address.</FormError>
            </FormFieldWrapper>
        </div>
    }
}

Dynamic Validation

Real-time form validation with instant feedback as users type in Input fields. This example showcases live validation in Leptos that checks email format dynamically and displays error messages immediately for improved user experience in Rust applications.

use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use validator::Validate;

use crate::components::hooks::use_form::use_form;
use crate::components::ui::form::{Form, FormError, FormField, FormGroup, FormInput, FormLabel, FormProvider, FormSet};

#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
struct FormData {
    #[validate(length(min = 3, message = "Name must be at least 3 characters long."))]
    name: String,
    #[validate(range(min = 18, max = 140, message = "Age must be between 18 and 140."))]
    age: u16,
    #[validate(email(message = "Enter a valid email address."))]
    email: String,
}

#[component]
pub fn DemoFormValidation() -> impl IntoView {
    let form = use_form::<FormData>();

    view! {
        <FormProvider form=form>
            <Form class="max-w-md">
                <FormSet>
                    <FormGroup>
                        <FormField field="name">
                            <FormLabel>Name</FormLabel>
                            <FormInput attr:placeholder="John Doe" />
                            <FormError />
                        </FormField>
                    </FormGroup>
                    <FormGroup>
                        <FormField field="age">
                            <FormLabel>Age</FormLabel>
                            <FormInput attr:r#type="number" attr:placeholder="25" />
                            <FormError />
                        </FormField>
                    </FormGroup>
                    <FormGroup>
                        <FormField field="email">
                            <FormLabel>Email</FormLabel>
                            <FormInput attr:r#type="email" attr:placeholder="[email protected]" />
                            <FormError />
                        </FormField>
                    </FormGroup>
                </FormSet>
            </Form>
        </FormProvider>
    }
}

Fieldset with Legend

Form fields organized using semantic fieldset and legend elements for better grouping. This example demonstrates accessible form structure in Leptos using FormSet and FormLegend components to create logical field groups with descriptive labels.

Address Information

We need your address to deliver your order.

use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use validator::Validate;

use crate::components::hooks::use_form::use_form;
use crate::components::ui::form::{
    Form, FormDescription, FormField, FormGroup, FormInput, FormLabel, FormLegend, FormLegendVariant, FormProvider,
    FormSet,
};

#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
struct FormData {
    street: String,
    city: String,
    zip: String,
}

#[component]
pub fn DemoFormFieldset() -> impl IntoView {
    let form = use_form::<FormData>();

    view! {
        <FormProvider form=form>
            <Form class="max-w-md">
                <FormSet>
                    <FormLegend variant=FormLegendVariant::Legend>Address Information</FormLegend>
                    <FormDescription>We need your address to deliver your order.</FormDescription>
                    <FormGroup>
                        <FormField field="street">
                            <FormLabel>Street Address</FormLabel>
                            <FormInput attr:placeholder="123 Main St" />
                        </FormField>
                        <div class="grid grid-cols-2 gap-4">
                            <FormField field="city">
                                <FormLabel>City</FormLabel>
                                <FormInput attr:placeholder="New York" />
                            </FormField>
                            <FormField field="zip">
                                <FormLabel>Postal Code</FormLabel>
                                <FormInput attr:placeholder="90502" />
                            </FormField>
                        </div>
                    </FormGroup>
                </FormSet>
            </Form>
        </FormProvider>
    }
}

Form with Select

Form integration with select dropdown components for option-based input. This example shows how to combine FormField with Select components in Leptos to build accessible forms with proper state binding in Rust applications.

  • Engineering
  • Design
  • Marketing
  • Sales
  • Customer Support
  • Human Resources
  • Finance
  • Operations

Select your department or area of work.

use leptos::prelude::*;

use crate::components::ui::form::{FormContent, FormDescription, FormLabel};
use crate::components::ui::select::{Select, SelectContent, SelectGroup, SelectOption, SelectTrigger, SelectValue};

const DEPARTMENTS: [&str; 8] =
    ["Engineering", "Design", "Marketing", "Sales", "Customer Support", "Human Resources", "Finance", "Operations"];

#[component]
pub fn DemoFormSelect() -> impl IntoView {
    let department_signal = RwSignal::new(None::<&str>);

    view! {
        <div class="w-full max-w-md">
            <FormContent>
                <FormLabel>Department</FormLabel>
                <Select>
                    <SelectTrigger>
                        <SelectValue placeholder="Choose department" />
                    </SelectTrigger>

                    <SelectContent>
                        <SelectGroup>
                            {DEPARTMENTS
                                .into_iter()
                                .map(|dept| {
                                    view! {
                                        <SelectOption
                                            aria_selected=Signal::derive(move || {
                                                department_signal.get() == Some(dept)
                                            })
                                            value=dept
                                            on:click=move |_| department_signal.set(Some(dept))
                                        >
                                            {dept}
                                        </SelectOption>
                                    }
                                })
                                .collect_view()}
                        </SelectGroup>
                    </SelectContent>
                </Select>
                <FormDescription>Select your department or area of work.</FormDescription>
            </FormContent>
        </div>
    }
}

Grouped Fields

Form fields organized into visual groups with separators for improved readability. This example demonstrates how to use FormGroup and FormSeparator in Leptos to create well-structured forms with clear section boundaries in Rust applications.

Get notified when ChatGPT responds to requests that take time, like research or image generation.

Get notified when tasks you've created have updates. Manage tasks

use leptos::prelude::*;

use crate::components::ui::checkbox::Checkbox;
use crate::components::ui::form::{FormDescription, FormGroup, FormLabel, FormSeparator, FormSet};

#[component]
pub fn DemoFormGroup() -> impl IntoView {
    view! {
        <div class="w-full max-w-md">
            <FormGroup>
                <FormSet>
                    <FormLabel>Responses</FormLabel>
                    <FormDescription>
                        Get notified when ChatGPT responds to requests that take time, like research or image generation.
                    </FormDescription>
                    <FormGroup attr:data-name="CheckboxGroup">
                        <div class="flex flex-row gap-3 items-center w-full">
                            <Checkbox attr:id="push" checked=true disabled=true />
                            <FormLabel html_for="push" class="font-normal">
                                Push notifications
                            </FormLabel>
                        </div>
                    </FormGroup>
                </FormSet>
                <FormSeparator />
                <FormSet>
                    <FormLabel>Tasks</FormLabel>
                    <FormDescription>
                        "Get notified when tasks you've created have updates. " <a href="#">Manage tasks</a>
                    </FormDescription>
                    <FormGroup attr:data-name="CheckboxGroup">
                        <div class="flex flex-row gap-3 items-center w-full">
                            <Checkbox attr:id="push-tasks" />
                            <FormLabel html_for="push-tasks" class="font-normal">
                                Push notifications
                            </FormLabel>
                        </div>
                        <div class="flex flex-row gap-3 items-center w-full">
                            <Checkbox attr:id="email-tasks" />
                            <FormLabel html_for="email-tasks" class="font-normal">
                                Email notifications
                            </FormLabel>
                        </div>
                    </FormGroup>
                </FormSet>
            </FormGroup>
        </div>
    }
}

Get notified when new stuff drops.

Rust/UI Icons - Send