Rust/UI components for building accessible form fields with labels, descriptions, and error messages.
input
- Copy Demo
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 --forceui add demo_fieldui 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
- Copy 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
- Copy Demo
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
- Copy Demo
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.
- Copy Demo
Enter a valid email address.
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.
- Copy 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>
}
}