Field.
Building blocks for accessible forms: labels, descriptions, errors, fieldsets and legends in vertical, horizontal or responsive layouts.
"use client";
import * as React from "react";
import { CloudIcon, UploadIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
export default function FieldDemo() {
const [published, setPublished] = React.useState(false);
return (
<form
className="w-full max-w-lg rounded-lg border bg-card p-5 sm:p-7"
onSubmit={(e) => {
e.preventDefault();
setPublished(true);
}}
>
<FieldGroup>
<FieldSet>
<FieldLegend>Publish a site</FieldLegend>
<FieldDescription>Claim an address and put your computer on the network.</FieldDescription>
<FieldGroup>
<Field>
<FieldLabel htmlFor="pub-name">Site name</FieldLabel>
<Input id="pub-name" placeholder="Ember Library" required />
</Field>
<Field>
<FieldLabel htmlFor="pub-address">Address</FieldLabel>
<InputGroup>
<InputGroupAddon>
<InputGroupText>hub://</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="pub-address"
placeholder="ember-library"
className="pl-0.5 font-mono text-[13px]"
aria-describedby="pub-address-desc"
required
/>
</InputGroup>
<FieldDescription id="pub-address-desc">Lowercase letters, numbers and dashes.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="pub-desc">Description</FieldLabel>
<Textarea id="pub-desc" placeholder="A reading room for books copied off the server." className="min-h-24" />
</Field>
<Field>
<FieldLabel htmlFor="pub-category">Category</FieldLabel>
<Select defaultValue="library">
<SelectTrigger id="pub-category" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="library">Library</SelectItem>
<SelectItem value="shop">Shop</SelectItem>
<SelectItem value="guestbook">Guestbook</SelectItem>
<SelectItem value="tools">Tools and scripts</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
<FieldSeparator />
<FieldSet>
<FieldLegend variant="label">Visibility</FieldLegend>
<RadioGroup defaultValue="listed" className="gap-3">
<FieldLabel htmlFor="pub-listed">
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>Listed</FieldTitle>
<FieldDescription>Shows up in the directory and search.</FieldDescription>
</FieldContent>
<RadioGroupItem value="listed" id="pub-listed" />
</Field>
</FieldLabel>
<FieldLabel htmlFor="pub-unlisted">
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>Unlisted</FieldTitle>
<FieldDescription>Only people with the address can visit.</FieldDescription>
</FieldContent>
<RadioGroupItem value="unlisted" id="pub-unlisted" />
</Field>
</FieldLabel>
</RadioGroup>
</FieldSet>
<FieldSeparator />
<Field orientation="horizontal">
<Checkbox id="pub-rules" required />
<FieldContent>
<FieldLabel htmlFor="pub-rules">I agree to the hub rules</FieldLabel>
<FieldDescription>No griefing links, no pages that pretend to be someone else.</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal" className="flex-wrap">
<Button type="submit">
{published ? <CloudIcon /> : <UploadIcon />}
{published ? "Published" : "Publish site"}
</Button>
<Button type="button" variant="ghost" onClick={() => setPublished(false)}>
Cancel
</Button>
</Field>
</FieldGroup>
</form>
);
}Installation#
With the CLI
$npx lanterncn add fieldThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/field. See Installation if your project is not set up yet.
Manually
Install the dependencies
npm install class-variance-authorityCopy the source into your project
components/ui/field.tsx"use client"; import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; import { Label } from "@/components/ui/label"; function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { return ( <fieldset data-slot="field-set" className={cn( "flex min-w-0 flex-col gap-6 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-3 data-[variant=label]:font-mono data-[variant=label]:text-[10px] data-[variant=label]:tracking-[0.2em] data-[variant=label]:text-success data-[variant=label]:uppercase", "data-[variant=legend]:font-display data-[variant=legend]:text-lg data-[variant=legend]:font-medium data-[variant=legend]:tracking-tight", 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-6 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4", className, )} {...props} /> ); } const fieldVariants = cva("group/field flex w-full gap-2.5 data-[invalid=true]:text-destructive", { variants: { orientation: { vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], 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-px", ], 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", "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", ], }, }, 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-1.5 leading-snug", className)} {...props} /> ); } 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-snug group-data-[disabled=true]/field:opacity-50", "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:cursor-pointer has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border has-[>[data-slot=field]]:border-input has-[>[data-slot=field]]:transition-[border-color,background-color,box-shadow] has-[>[data-slot=field]]:hover:border-muted-foreground/60 [&>*]:data-[slot=field]:p-4", "has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/8 has-data-[state=checked]:shadow-[4px_5px_0_var(--block-shadow)] has-data-[state=checked]:hover:border-primary", className, )} {...props} /> ); } function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="field-label" className={cn( "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50", className, )} {...props} /> ); } function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { return ( <p data-slot="field-description" className={cn( "text-[13px] leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance", "last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5", "[&>a]:text-primary [&>a]:underline-offset-4 [&>a:hover]:underline", className, )} {...props} /> ); } function FieldSeparator({ children, className, ...props }: React.ComponentProps<"div"> & { children?: React.ReactNode }) { return ( <div data-slot="field-separator" data-content={!!children} className={cn("-my-1 flex items-center gap-3", className)} {...props} > <div aria-hidden="true" className="h-px flex-1 bg-border" /> {children && ( <> <span data-slot="field-separator-content" className="font-mono text-[10px] tracking-[0.2em] text-muted-foreground uppercase" > {children} </span> <div aria-hidden="true" className="h-px flex-1 bg-border" /> </> )} </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 uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()]; if (uniqueErrors.length === 1) return uniqueErrors[0]?.message; return ( <ul className="ml-4 flex list-disc flex-col gap-1"> {uniqueErrors.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("text-[13px] font-normal text-destructive", className)} {...props} > {content} </div> ); } export { Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldLegend, FieldSeparator, FieldSet, FieldContent, FieldTitle, };Update the import paths to match your project
The source imports
cnfrom@/lib/utilsand uses label.
Usage#
import {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
} from "@/components/ui/field";<FieldGroup>
<Field>
<FieldLabel htmlFor="name">Site name</FieldLabel>
<Input id="name" />
<FieldDescription>Shown in the directory.</FieldDescription>
</Field>
<Field orientation="horizontal">
<Switch id="online" />
<FieldLabel htmlFor="online">Keep site online</FieldLabel>
</Field>
</FieldGroup>Set data-invalid on a Field and aria-invalid on its control to show an error. FieldError takes children or an errors array, such as the output of a schema validator.
Wrap a horizontal Field in a FieldLabel to turn a radio or checkbox into a selectable card.
orientation="responsive" stacks on narrow containers and goes side by side once the parent FieldGroup is wide enough.
Examples#
Sign in
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldSeparator } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
function GitHubMark(props: React.ComponentProps<"svg">) {
return (
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" {...props}>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
);
}
export default function FieldLogin() {
return (
<Card className="w-full max-w-sm shadow-block-sm">
<CardHeader>
<CardTitle>Sign in to the Hub</CardTitle>
<CardDescription>Manage your sites and paired computers.</CardDescription>
</CardHeader>
<CardContent>
<form>
<FieldGroup>
<Field>
<Button type="button" variant="secondary" className="w-full">
<GitHubMark />
Continue with GitHub
</Button>
</Field>
<FieldSeparator>or</FieldSeparator>
<Field>
<FieldLabel htmlFor="login-email">Email</FieldLabel>
<Input id="login-email" type="email" placeholder="you@example.com" autoComplete="email" required />
</Field>
<Field>
<div className="flex items-center justify-between gap-3">
<FieldLabel htmlFor="login-password">Password</FieldLabel>
<a href="#" className="text-xs text-primary underline-offset-4 hover:underline">
Forgot it?
</a>
</div>
<Input id="login-password" type="password" autoComplete="current-password" required />
</Field>
<Field>
<Button type="submit" className="w-full">
Sign in
</Button>
<FieldDescription className="text-center">
New here? <a href="#">Create an account</a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
);
}Sign up with validation
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
type Errors = Partial<Record<"player" | "email" | "password" | "terms", { message: string }[]>>;
function validate(data: FormData): Errors {
const errors: Errors = {};
const player = String(data.get("player") ?? "");
const email = String(data.get("email") ?? "");
const password = String(data.get("password") ?? "");
if (player.length < 3) errors.player = [{ message: "Player name needs at least 3 characters." }];
else if (/\s/.test(player)) errors.player = [{ message: "Player names cannot contain spaces." }];
if (!/^\S+@\S+\.\S+$/.test(email)) errors.email = [{ message: "Enter a valid email address." }];
const pw: { message: string }[] = [];
if (password.length < 8) pw.push({ message: "Use at least 8 characters." });
if (!/\d/.test(password)) pw.push({ message: "Include at least one number." });
if (pw.length) errors.password = pw;
if (!data.get("terms")) errors.terms = [{ message: "You need to accept the hub rules." }];
return errors;
}
export default function FieldSignup() {
const [errors, setErrors] = React.useState<Errors>({
player: [{ message: "Player names cannot contain spaces." }],
password: [{ message: "Use at least 8 characters." }, { message: "Include at least one number." }],
});
const [done, setDone] = React.useState(false);
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Create an account</CardTitle>
<CardDescription>One account for every server you play on.</CardDescription>
</CardHeader>
<CardContent>
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
const next = validate(new FormData(e.currentTarget));
setErrors(next);
setDone(Object.keys(next).length === 0);
}}
>
<FieldGroup>
<Field data-invalid={!!errors.player}>
<FieldLabel htmlFor="su-player">Player name</FieldLabel>
<Input
id="su-player"
name="player"
defaultValue="steve builder"
aria-invalid={!!errors.player}
aria-describedby="su-player-error"
/>
<FieldError id="su-player-error" errors={errors.player} />
</Field>
<Field data-invalid={!!errors.email}>
<FieldLabel htmlFor="su-email">Email</FieldLabel>
<Input
id="su-email"
name="email"
type="email"
defaultValue="steve@example.com"
aria-invalid={!!errors.email}
aria-describedby="su-email-error"
/>
<FieldError id="su-email-error" errors={errors.email} />
</Field>
<Field data-invalid={!!errors.password}>
<FieldLabel htmlFor="su-password">Password</FieldLabel>
<Input
id="su-password"
name="password"
type="password"
defaultValue="turtle"
aria-invalid={!!errors.password}
aria-describedby="su-password-error"
/>
<FieldError id="su-password-error" errors={errors.password} />
</Field>
<Field orientation="horizontal" data-invalid={!!errors.terms}>
<Checkbox id="su-terms" name="terms" aria-invalid={!!errors.terms} aria-describedby="su-terms-error" />
<FieldContent>
<FieldLabel htmlFor="su-terms">I accept the hub rules</FieldLabel>
<FieldError id="su-terms-error" errors={errors.terms} />
</FieldContent>
</Field>
<Field>
<Button type="submit" className="w-full">
Create account
</Button>
{done && (
<FieldDescription role="status" className="text-center text-success">
Account created. Check your email to confirm.
</FieldDescription>
)}
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
);
}Settings
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
} from "@/components/ui/field";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
const toggles = [
{ id: "online", label: "Keep site online", description: "Serve a cached copy from the Hub while your computer is off.", on: true },
{ id: "guestbook", label: "Open guestbook", description: "Visitors can leave short messages.", on: true },
{ id: "analytics", label: "Visit counter", description: "Count page views without storing player names.", on: false },
];
export default function FieldSettings() {
const [interval, setInterval] = React.useState([15]);
return (
<form className="w-full max-w-lg rounded-lg border bg-card p-5 sm:p-7" onSubmit={(e) => e.preventDefault()}>
<FieldGroup>
<FieldSet>
<FieldLegend>Site settings</FieldLegend>
<FieldDescription>Changes sync to your computer the next time it boots.</FieldDescription>
<FieldGroup className="gap-5">
{toggles.map((t) => (
<Field key={t.id} orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor={`fs-${t.id}`}>{t.label}</FieldLabel>
<FieldDescription id={`fs-${t.id}-desc`}>{t.description}</FieldDescription>
</FieldContent>
<Switch id={`fs-${t.id}`} defaultChecked={t.on} aria-describedby={`fs-${t.id}-desc`} />
</Field>
))}
</FieldGroup>
</FieldSet>
<FieldSeparator />
<FieldSet>
<FieldLegend variant="label">Sync</FieldLegend>
<FieldGroup>
<Field>
<div className="flex items-center justify-between gap-3">
<FieldLabel id="fs-interval-label">Sync interval</FieldLabel>
<span className="font-mono text-xs text-primary">every {interval[0]} min</span>
</div>
<Slider
value={interval}
onValueChange={setInterval}
min={5}
max={60}
step={5}
aria-labelledby="fs-interval-label"
aria-describedby="fs-interval-desc"
/>
<FieldDescription id="fs-interval-desc">Shorter intervals use more of your modem's range.</FieldDescription>
</Field>
<Field orientation="responsive">
<FieldContent>
<FieldLabel htmlFor="fs-conflict">On conflict</FieldLabel>
<FieldDescription>When the Hub copy and your computer disagree.</FieldDescription>
</FieldContent>
<Select defaultValue="computer">
<SelectTrigger id="fs-conflict" className="w-full @md/field-group:w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="computer">Computer wins</SelectItem>
<SelectItem value="hub">Hub wins</SelectItem>
<SelectItem value="ask">Ask me</SelectItem>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
<Field orientation="horizontal" className="justify-end">
<Button type="button" variant="ghost">
Reset
</Button>
<Button type="submit">Save settings</Button>
</Field>
</FieldGroup>
</form>
);
}Contact
"use client";
import * as React from "react";
import { SendIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
export default function FieldContact() {
const [message, setMessage] = React.useState("");
const [sent, setSent] = React.useState(false);
const max = 500;
return (
<form
className="w-full max-w-lg rounded-lg border bg-card p-5 sm:p-7"
onSubmit={(e) => {
e.preventDefault();
setSent(true);
}}
>
<FieldSet>
<FieldLegend>Message the site owner</FieldLegend>
<FieldDescription>Delivered to their in-game inbox the next time they log on.</FieldDescription>
<FieldGroup>
<div className="grid gap-6 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="ct-name">Your name</FieldLabel>
<Input id="ct-name" placeholder="Alex" autoComplete="name" required />
</Field>
<Field>
<FieldLabel htmlFor="ct-email">Reply to</FieldLabel>
<Input id="ct-email" type="email" placeholder="you@example.com" autoComplete="email" />
</Field>
</div>
<Field>
<FieldLabel htmlFor="ct-topic">Topic</FieldLabel>
<Select defaultValue="trade">
<SelectTrigger id="ct-topic" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="trade">Trade request</SelectItem>
<SelectItem value="bug">Something is broken</SelectItem>
<SelectItem value="hello">Just saying hello</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="ct-message">Message</FieldLabel>
<Textarea
id="ct-message"
value={message}
maxLength={max}
onChange={(e) => setMessage(e.target.value)}
placeholder="I have 3 stacks of cobblestone for your diamond pickaxe."
className="min-h-28"
aria-describedby="ct-count"
required
/>
<FieldDescription id="ct-count" className="text-right font-mono text-[11px]">
{message.length}/{max}
</FieldDescription>
</Field>
<Field orientation="horizontal" className="flex-wrap justify-between">
<FieldDescription role="status" className={sent ? "text-success" : undefined}>
{sent ? "Sent. It will arrive when they log on." : "Owner last seen 2 hours ago."}
</FieldDescription>
<Button type="submit">
<SendIcon />
Send message
</Button>
</Field>
</FieldGroup>
</FieldSet>
</form>
);
}Newsletter
"use client";
import * as React from "react";
import { ArrowRightIcon, CheckIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
export default function FieldNewsletter() {
const [joined, setJoined] = React.useState(false);
return (
<form
className="w-full max-w-md"
onSubmit={(e) => {
e.preventDefault();
setJoined(true);
}}
>
<Field>
<FieldLabel htmlFor="nl-email" className="font-mono text-[10px] tracking-[0.2em] text-success uppercase">
Network dispatch
</FieldLabel>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="nl-email"
type="email"
placeholder="you@example.com"
autoComplete="email"
aria-describedby="nl-desc"
required
className="flex-1"
/>
<Button type="submit" className="shrink-0">
{joined ? <CheckIcon /> : null}
{joined ? "Subscribed" : "Subscribe"}
{joined ? null : <ArrowRightIcon />}
</Button>
</div>
<FieldDescription id="nl-desc" role="status">
{joined ? "You are on the list. See you next month." : "New sites and server news, once a month. No spam."}
</FieldDescription>
</Field>
</form>
);
}