Field

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

input
  • Rust/UI Icons - CopyCopy Demo

Choose a unique username for your account.

Must be at least 8 characters long.

use leptos::prelude::*;

use crate::components::ui::field::{Field, FieldDescription, FieldGroup, FieldLabel, FieldSet};
use crate::components::ui::input::Input;

#[component]
pub fn DemoField() -> impl IntoView {
    view! {
        <div class="w-full max-w-md">
            <FieldSet>
                <FieldGroup>
                    <Field>
                        <FieldLabel html_for="username">Username</FieldLabel>
                        <Input attr:id="username" attr:r#type="text" attr:placeholder="Max Wells" />
                        <FieldDescription>Choose a unique username for your account.</FieldDescription>
                    </Field>
                    <Field>
                        <FieldLabel html_for="password">Password</FieldLabel>
                        <FieldDescription>Must be at least 8 characters long.</FieldDescription>
                        <Input attr:id="password" attr:r#type="password" attr:placeholder="********" />
                    </Field>
                </FieldGroup>
            </FieldSet>
        </div>
    }
}

Installation

You can run either of the following commands:

# cargo install ui-cli --force
ui add demo_field
ui add field

Update the imports to match your project setup.

Copy and paste the following code into your project:

components/ui/field.rs

use leptos::prelude::*;
use leptos_ui::{clx, variants};
use tw_merge::tw_merge;

use crate::registry::ui::label::Label;
use crate::registry::ui::separator::Separator;

mod components {
    use super::*;

    clx! {FieldSet, fieldset, "flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3"}
    clx! {FieldGroup, div, "group/field-group @container/field-group flex flex-col gap-7 w-full data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4"}
    clx! {FieldContent, div, "group/field-content flex flex-1 flex-col gap-1.5 leading-snug"}
    clx! {FieldTitle, div, "flex items-center gap-2 text-sm leading-snug font-medium w-fit group-data-[disabled=true]/field:opacity-50"}
    clx! {FieldDescription, 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 FieldLegend(
    #[prop(into, optional)] class: String,
    #[prop(default = FieldLegendVariant::Legend)] variant: FieldLegendVariant,
    children: Children,
) -> impl IntoView {
    let variant_attr = match variant {
        FieldLegendVariant::Legend => "legend",
        FieldLegendVariant::Label => "label",
    };

    let merged_class =
        tw_merge!("mb-3 font-medium data-[variant=legend]:text-base data-[variant=label]:text-sm", class);

    view! {
        <legend attr:data-slot="field-legend" attr:data-variant=variant_attr class=merged_class>
            {children()}
        </legend>
    }
}

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

variants! {
    Field {
        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-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[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-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
            },
            size: {
                Default: "",
            }
        },
        component: {
            element: div
        }
    }
}

#[component]
pub fn FieldLabel(
    #[prop(into, optional)] class: String,
    #[prop(into, optional)] html_for: String,
    children: Children,
) -> impl IntoView {
    let merged_class = tw_merge!(
        "group/field-label peer/field-label flex gap-2 leading-snug w-fit group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=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-slot="field-label" class=merged_class html_for=html_for>
            {children()}
        </Label>
    }
}

#[component]
pub fn FieldSeparator(
    #[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-slot="field-separator" 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-slot="field-separator-content"
                        >
                            {children()}
                        </span>
                    }
                })}
        </div>
    }
}

#[component]
pub fn FieldError(
    #[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-slot="field-error"
                class=tw_merge!("text-destructive text-sm font-normal", &class)
            >
                {children()}
            </div>
        }
        .into_any();
    }

    // Otherwise, handle errors reactively
    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-slot="field-error"
                                    class=tw_merge!("text-destructive text-sm font-normal", &class)
                                >
                                    <span>{errors[0].clone()}</span>
                                </div>
                            },
                        )
                    } else {
                        Some(
                            view! {
                                <div
                                    role="alert"
                                    attr:data-slot="field-error"
                                    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()
}

Update the imports to match your project setup.

Usage

use crate::components::ui::field::{
    Field,
    FieldContent,
    FieldDescription,
    FieldError,
    FieldGroup,
    FieldLabel,
    FieldLegend,
    FieldLegendVariant,
    FieldSeparator,
    FieldSet,
    FieldTitle,
    FieldVariant,
};
use crate::components::ui::input::Input;
use crate::components::ui::switch::Switch;
<FieldSet>
    <FieldLegend variant=FieldLegendVariant::Legend>Profile</FieldLegend>
    <FieldDescription>This appears on invoices and emails.</FieldDescription>
    <FieldGroup>
        <Field>
            <FieldLabel html_for="name">Full name</FieldLabel>
            <Input attr:id="name" attr:autocomplete="off" attr:placeholder="Evil Rabbit" />
            <FieldDescription>This appears on invoices and emails.</FieldDescription>
        </Field>
        <Field>
            <FieldLabel html_for="username">Username</FieldLabel>
            <Input attr:id="username" attr:autocomplete="off" attr:aria-invalid="true" />
            <FieldError>Choose another username.</FieldError>
        </Field>
        <Field variant=FieldVariant::Horizontal>
            <Switch attr:id="newsletter" />
            <FieldLabel html_for="newsletter">Subscribe to the newsletter</FieldLabel>
        </Field>
    </FieldGroup>
</FieldSet>

Components

The Field component is composed of several subcomponents:

  • FieldSet: Wraps a group of related fields
  • FieldGroup: Groups multiple fields together
  • Field: Individual field container
  • FieldLabel: Label for the field
  • FieldDescription: Helper text for the field
  • FieldError: Error message display
  • FieldContent: Content wrapper for field elements
  • FieldTitle: Title text for fields
  • FieldLegend: Legend for fieldsets
  • FieldSeparator: Visual separator between fields

Examples

Field with Select

  • Rust/UI Icons - CopyCopy Demo

Select your department or area of work.

use leptos::prelude::*;
use strum::{AsRefStr, Display, EnumString};

use crate::components::_coming_soon::select::{
    Select, SelectContent, SelectGroup, SelectItem, SelectOption, SelectTrigger, SelectValue,
};
use crate::components::ui::field::{Field, FieldDescription, FieldLabel};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, AsRefStr)]
enum Department {
    Engineering,
    Design,
    Marketing,
    Sales,
    #[strum(serialize = "Customer Support")]
    CustomerSupport,
    #[strum(serialize = "Human Resources")]
    HumanResources,
    Finance,
    Operations,
}

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

    let departments = [
        Department::Engineering,
        Department::Design,
        Department::Marketing,
        Department::Sales,
        Department::CustomerSupport,
        Department::HumanResources,
        Department::Finance,
        Department::Operations,
    ];

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

                    <SelectContent>
                        <SelectGroup>
                            {departments
                                .into_iter()
                                .map(|dept| {
                                    let dept_str = dept.to_string();
                                    view! {
                                        <SelectItem>
                                            <SelectOption
                                                aria_selected=Signal::derive(move || {
                                                    department_signal.get() == Some(dept)
                                                })
                                                value=dept_str.clone()
                                                on:click=move |_| department_signal.set(Some(dept))
                                            >
                                                {dept_str}
                                            </SelectOption>
                                        </SelectItem>
                                    }
                                })
                                .collect_view()}
                        </SelectGroup>
                    </SelectContent>
                </Select>
                <FieldDescription>Select your department or area of work.</FieldDescription>
            </Field>
        </div>
    }
}

FieldSet with FieldLegend

  • Rust/UI Icons - CopyCopy Demo
Address Information

We need your address to deliver your order.

use leptos::prelude::*;

use crate::components::ui::field::{
    Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldLegendVariant, FieldSet,
};
use crate::components::ui::input::Input;

#[component]
pub fn DemoFieldFieldset() -> impl IntoView {
    view! {
        <div class="space-y-6 w-full max-w-md">
            <FieldSet>
                <FieldLegend variant=FieldLegendVariant::Legend>Address Information</FieldLegend>
                <FieldDescription>We need your address to deliver your order.</FieldDescription>
                <FieldGroup>
                    <Field>
                        <FieldLabel html_for="street">Street Address</FieldLabel>
                        <Input attr:id="street" attr:r#type="text" attr:placeholder="123 Main St" />
                    </Field>
                    <div class="grid grid-cols-2 gap-4">
                        <Field>
                            <FieldLabel html_for="city">City</FieldLabel>
                            <Input attr:id="city" attr:r#type="text" attr:placeholder="New York" />
                        </Field>
                        <Field>
                            <FieldLabel html_for="zip">Postal Code</FieldLabel>
                            <Input attr:id="zip" attr:r#type="text" attr:placeholder="90502" />
                        </Field>
                    </div>
                </FieldGroup>
            </FieldSet>
        </div>
    }
}

FieldGroup with Checkboxes

  • Rust/UI Icons - CopyCopy Demo

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::field::{
    Field, FieldDescription, FieldGroup, FieldLabel, FieldSeparator, FieldSet, FieldVariant,
};

#[component]
pub fn DemoFieldGroup() -> impl IntoView {
    view! {
        <div class="w-full max-w-md">
            <FieldGroup>
                <FieldSet>
                    <FieldLabel>Responses</FieldLabel>
                    <FieldDescription>
                        Get notified when ChatGPT responds to requests that take time, like research or image generation.
                    </FieldDescription>
                    <FieldGroup attr:data-slot="checkbox-group">
                        <Field variant=FieldVariant::Horizontal>
                            <Checkbox attr:id="push" checked=true disabled=true />
                            <FieldLabel html_for="push" class="font-normal">
                                Push notifications
                            </FieldLabel>
                        </Field>
                    </FieldGroup>
                </FieldSet>
                <FieldSeparator />
                <FieldSet>
                    <FieldLabel>Tasks</FieldLabel>
                    <FieldDescription>
                        "Get notified when tasks you've created have updates. " <a href="#">Manage tasks</a>
                    </FieldDescription>
                    <FieldGroup attr:data-slot="checkbox-group">
                        <Field variant=FieldVariant::Horizontal>
                            <Checkbox attr:id="push-tasks" />
                            <FieldLabel html_for="push-tasks" class="font-normal">
                                Push notifications
                            </FieldLabel>
                        </Field>
                        <Field variant=FieldVariant::Horizontal>
                            <Checkbox attr:id="email-tasks" />
                            <FieldLabel html_for="email-tasks" class="font-normal">
                                Email notifications
                            </FieldLabel>
                        </Field>
                    </FieldGroup>
                </FieldSet>
            </FieldGroup>
        </div>
    }
}

Validation and Errors

Add data-invalid to Field to switch the entire block into an error state. Add aria-invalid on the input itself for assistive technologies. Render FieldError immediately after the control to keep error messages aligned with the field.

  • Rust/UI Icons - CopyCopy Demo
use leptos::prelude::*;

use crate::components::ui::field::{Field, FieldError, FieldLabel};
use crate::components::ui::input::Input;

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

Dynamic Validation

This example demonstrates real-time validation that shows errors as the user types. The email field validates the input and displays an error message if the format is invalid.

  • Rust/UI Icons - CopyCopy Demo
use leptos::prelude::*;

use crate::components::ui::field::{Field, FieldError, FieldGroup, FieldLabel, FieldSet};
use crate::components::ui::input::Input;

fn is_valid_email(email: &str) -> bool {
    email.contains('@') && email.contains('.') && email.len() > 5
}

#[component]
pub fn DemoFieldValidation() -> impl IntoView {
    let email_signal = RwSignal::new(String::new());
    let error_signal = RwSignal::new(None::<String>);

    let validate_email = move |_| {
        let email = email_signal.get();
        if email.is_empty() {
            error_signal.set(None);
        } else if !is_valid_email(&email) {
            error_signal.set(Some("Enter a valid email address.".to_string()));
        } else {
            error_signal.set(None);
        }
    };

    let has_error = move || error_signal.get().is_some();

    view! {
        <div class="w-full max-w-md">
            <FieldSet>
                <FieldGroup>
                    <Field attr:data-invalid=move || has_error().then_some("true")>
                        <FieldLabel html_for="email">Email</FieldLabel>
                        <Input
                            attr:id="email"
                            attr:r#type="email"
                            attr:placeholder="[email protected]"
                            attr:aria-invalid=move || has_error().then_some("true")
                            on:input=move |ev| {
                                email_signal.set(event_target_value(&ev));
                                validate_email(());
                            }
                        />
                        {move || {
                            error_signal
                                .get()
                                .map(|err| {
                                    view! { <FieldError>{err}</FieldError> }
                                })
                        }}
                    </Field>
                </FieldGroup>
            </FieldSet>
        </div>
    }
}