Component
Field
A field's label, control, description and error stacked 4px apart, fields 12px apart, with the 'or' hairline between two ways in.
npx -y github:anwarlemu/inkbox-brand-cli#release add fieldEnter a vault key or generate a random one. You'll need this to unlock the vault.
Days to keep delivered messages before they are deleted.
Usage
Wrap every form control in Field with a FieldLabel above it; add FieldDescription for a hint (Enter a vault key or generate a random one) and FieldError, with data-invalid on the Field and aria-invalid on the control, when the value is rejected. Group fields in FieldGroup, related choices in FieldSet with a FieldLegend. Put an action beside the label (Forgot password?) in a row with justify-between. Use orientation horizontal for a label beside its control, and FieldSeparator with "or" between a form and other ways in.
Avoid
- A red label on an invalid field
- A placeholder as the only label
- Errors larger than 12px
- Fields closer than 12px
Exports: Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSeparator, FieldSet, FieldTitle. This component is a client component.
Dependencies: label, class-variance-authority.
Example
import { Button } from "@/components/ui/button";import { Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSeparator, FieldSet, FieldTitle } from "@/components/ui/field";import { Input } from "@/components/ui/input";​export function Preview() { return ( <form className="mx-auto w-full max-w-86"> <FieldGroup> <Field> <FieldLabel htmlFor="login-email">Email address</FieldLabel> <Input id="login-email" type="email" placeholder="your@email.com" autoComplete="email" /> </Field> <Field> <div className="flex items-center justify-between gap-3"> <FieldLabel htmlFor="login-password">Password</FieldLabel> <a href="#forgot" className="text-body leading-body text-fg-brand underline-offset-4 hover:underline"> Forgot password? </a> </div> <Input id="login-password" type="password" autoComplete="current-password" /> </Field> <Button type="submit" className="w-full">Log in</Button> <FieldSeparator>or</FieldSeparator> <Button variant="secondary" className="w-full">Continue with Google</Button> </FieldGroup> </form> );}​export function Description() { return ( <Field className="max-w-162.5"> <FieldLabel htmlFor="vault-key">Vault key</FieldLabel> <FieldDescription>Enter a vault key or generate a random one. You'll need this to unlock the vault.</FieldDescription> <Input id="vault-key" type="password" defaultValue="correct-horse-battery" /> </Field> );}​export function Invalid() { return ( <Field data-invalid="true" className="max-w-100"> <FieldLabel htmlFor="handle">Agent handle</FieldLabel> <Input id="handle" defaultValue="acme-support" aria-invalid aria-describedby="handle-error" /> <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-caption leading-body text-fg"> Will be displayed as <span className="rounded-sm bg-subtle px-1.5 py-0.5">@acme-support</span> <FieldError id="handle-error">(is not available)</FieldError> </div> </Field> );}​export function Horizontal() { return ( <FieldGroup className="max-w-120"> <Field orientation="horizontal"> <FieldContent> <FieldTitle>Retention</FieldTitle> <FieldDescription>Days to keep delivered messages before they are deleted.</FieldDescription> </FieldContent> <Input aria-label="Retention in days" type="number" defaultValue={30} className="w-24" /> </Field> <Field orientation="responsive"> <FieldLabel htmlFor="display-name">Display name</FieldLabel> <Input id="display-name" placeholder="Agent display name" className="@md/field-group:w-64" /> </Field> </FieldGroup> );}​export function Fieldset() { return ( <FieldSet className="max-w-120"> <FieldLegend>Webhook destination</FieldLegend> <FieldDescription>Inkbox signs every delivery with the key for this agent identity.</FieldDescription> <FieldGroup> <Field> <FieldLabel htmlFor="webhook-url">Endpoint URL</FieldLabel> <Input id="webhook-url" type="url" placeholder="https://example.com/inkbox" /> </Field> <Field data-invalid="true"> <FieldLabel htmlFor="webhook-secret">Signing secret</FieldLabel> <Input id="webhook-secret" aria-invalid defaultValue="short" /> <FieldError errors={[{ message: "Use at least 32 characters." }, { message: "Include letters and numbers." }]} /> </Field> </FieldGroup> </FieldSet> );}Source
"use client";​import * as React from "react";import { cva, type VariantProps } from "class-variance-authority";​import { Label } from "@/components/ui/label";import { cn } from "@/lib/utils";​// A form's fields as the console lays them out (Figma 427:2715, Login, Vault, Create Agent Identity). A field is its// 14px label on a 24px line, 4px above the control; fields sit 12px apart. A description under it is 14px muted// (Vault key: "Enter a vault key or generate a random one"); an error is 12px red (is not available). "or" between two// ways in is 14px muted on a hairline. A field marked data-invalid keeps its label ink: only the control and the// message turn red.function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { return <fieldset data-slot="field-set" className={cn("m-0 flex min-w-0 flex-col gap-4 border-0 p-0 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", className)} {...props} />;}​function FieldLegend({ className, variant = "legend", ...props }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { return ( <legend data-slot="field-legend" data-variant={variant} className={cn("mb-1 p-0 font-sans text-fg data-[variant=label]:text-body data-[variant=label]:leading-body data-[variant=label]:font-normal data-[variant=legend]:text-body-lg data-[variant=legend]:leading-body data-[variant=legend]:font-medium", className)} {...props} /> );}​function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="field-group" className={cn("group/field-group @container/field-group flex w-full flex-col gap-3 [&>[data-slot=field-group]]:gap-4", className)} {...props} />;}​const fieldVariants = cva("group/field flex w-full gap-1 data-[disabled=true]:opacity-50", { variants: { orientation: { // Label over control (Email address over its field). vertical: "flex-col [&>*]:w-full [&>.sr-only]:w-auto", // Label beside its control (a channel row and its switch, a checkbox and its label). horizontal: "flex-row items-center gap-3 [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],&>[role=radio]]:mt-0.5", // Over on a narrow form, beside from 28rem of container. responsive: "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:gap-3 @md/field-group:[&>*]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto", }, }, defaultVariants: { orientation: "vertical" },});​function Field({ className, orientation = "vertical", ...props }: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) { return <div role="group" data-slot="field" data-orientation={orientation} className={cn(fieldVariants({ orientation }), className)} {...props} />;}​function FieldContent({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="field-content" className={cn("group/field-content flex min-w-0 flex-1 flex-col gap-0.5 leading-body", className)} {...props} />;}​// A label that wraps a whole field becomes a choice card: the field edge and 8px corners, 14px in; the checked one takes// the purple edge on the lilac.function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) { return ( <Label data-slot="field-label" className={cn( "group/field-label peer/field-label flex w-fit gap-2 leading-body group-data-[disabled=true]/field:opacity-50", "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-control has-[>[data-slot=field]]:border has-[>[data-slot=field]]:border-line-field has-[>[data-slot=field]]:transition-[background-color,border-color] has-[>[data-slot=field]]:duration-150 has-[>[data-slot=field]]:ease-out *:data-[slot=field]:p-3.5 has-[>[data-slot=field]]:hover:bg-subtle has-data-[state=checked]:border-line-brand has-data-[state=checked]:bg-subtle motion-reduce:transition-none", className, )} {...props} /> );}​function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="field-title" className={cn("flex w-fit items-center gap-2 font-sans text-body leading-body font-medium text-fg group-data-[disabled=true]/field:opacity-50", className)} {...props} />;}​function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { return ( <p data-slot="field-description" className={cn( "m-0 font-sans text-body leading-body font-normal text-fg-muted group-has-[[data-orientation=horizontal]]/field:text-balance [[data-variant=legend]+&]:-mt-1 [&>a]:text-fg-brand [&>a]:underline-offset-4 [&>a:hover]:underline", className, )} {...props} /> );}​/** The "or" between two ways in: a hairline across the form with a 14px muted word on the page color. */function FieldSeparator({ children, className, ...props }: React.ComponentProps<"div"> & { children?: React.ReactNode }) { return ( <div data-slot="field-separator" data-content={!!children} className={cn("relative flex h-6 items-center text-body leading-body", className)} {...props}> <div aria-hidden className="absolute inset-x-0 top-1/2 h-px bg-line-subtle" /> {children && ( <span data-slot="field-separator-content" className="relative mx-auto block w-fit bg-surface px-3 text-fg-muted"> {children} </span> )} </div> );}​function FieldError({ className, children, errors, ...props }: React.ComponentProps<"div"> & { errors?: Array<{ message?: string } | undefined> }) { const content = React.useMemo(() => { if (children) return children; if (!errors?.length) return null; const unique = [...new Map(errors.map((error) => [error?.message, error])).values()]; if (unique.length === 1) return unique[0]?.message; return <ul className="m-0 flex list-disc flex-col gap-0.5 pl-4">{unique.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}</ul>; }, [children, errors]); if (!content) return null; return ( <div role="alert" data-slot="field-error" className={cn("font-sans text-caption leading-body font-normal text-danger", className)} {...props}> {content} </div> );}​export { Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSeparator, FieldSet, FieldTitle };Reference: Figma 427:2715: Login (Email address, Password, Forgot password?, or), Vault (Vault key and its description), Create Agent Identity (is not available, Will be displayed as).