Forms

Field.

Building blocks for accessible forms: labels, descriptions, errors, fieldsets and legends in vertical, horizontal or responsive layouts.

Publish a site

Claim an address and put your computer on the network.

hub://

Lowercase letters, numbers and dashes.

Visibility

No griefing links, no pages that pretend to be someone else.

Installation#

With the CLI

$npx lanterncn add field

This 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

  1. Install the dependencies

    npm install class-variance-authority
  2. Copy 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,
    };
  3. Update the import paths to match your project

    The source imports cn from @/lib/utils and 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

Sign in to the Hub
Manage your sites and paired computers.
or
Forgot it?

New here? Create an account

Sign up with validation

Create an account
One account for every server you play on.

Settings

Site settings

Changes sync to your computer the next time it boots.

Serve a cached copy from the Hub while your computer is off.

Visitors can leave short messages.

Count page views without storing player names.

Sync
every 15 min

Shorter intervals use more of your modem's range.

When the Hub copy and your computer disagree.

Contact

Message the site owner

Delivered to their in-game inbox the next time they log on.

0/500

Owner last seen 2 hours ago.

Newsletter

New sites and server news, once a month. No spam.