suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Components82
  • Overview
  • Accordion
  • Alert
  • Alert Dialog
  • Alert Sheet
  • Amount Input
  • Aspect Ratio
  • Attachment
  • Avatar
  • Badge
  • Bottom Bar
  • Breadcrumb
  • Bubble
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Chip
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Dialog
  • Direction
  • Drawer
  • Dropdown Menu
  • Empty
  • FAB
  • Field
  • Fullscreen Menu
  • Haptics
  • Header
  • Hover Card
  • Icon
  • Input
  • Input Accessory Bar
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Keyboard Scroll View
  • Keypad
  • Label
  • Marker
  • Menubar
  • Message
  • Message Scroller
  • Native Select
  • Navigation Menu
  • Page Indicator
  • Pagination
  • Pin Pad
  • Popover
  • Progress
  • QR Scanner
  • Questionnaire
  • Radio Group
  • Resizable
  • Screen
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Signature Pad
  • Skeleton
  • Slider
  • Spinner
  • Switch
  • Tab Bar
  • Table
  • Tabs
  • Text
  • Textarea
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip
  • Wheel Picker
  • Widget

suiss/ui · Component

Field

Building blocks for form fields that pair controls with labels, descriptions and error messages.

Field is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export default function FieldDemo() {
  return (
    <div className="w-96">
      <Field orientation="vertical">
        <FieldLabel htmlFor="field-username">Username</FieldLabel>
        <Input id="field-username" placeholder="janedoe" />
        <FieldDescription>
          Choose a unique name for your account.
        </FieldDescription>
      </Field>
    </div>
  )
}

A Field wraps one control together with its FieldLabel, an optional FieldDescription and an optional FieldError. It automatically adds the ids of its descriptions and errors to the control's aria-describedby, so supporting text is announced with the control. Use FieldContent to stack a label and description beside a control in a horizontal field, and FieldTitle for a non-label heading inside a field.

FieldGroup stacks fields with consistent spacing, and its inset variant groups them into a rounded list, like iOS Settings. FieldSet and FieldLegend group related fields under a caption, and FieldSeparator divides sections, optionally with text. Use these parts with any control in the library, such as Input, Textarea, Checkbox, Switch or RadioGroup.

Installation

CLI

$ pnpm dlx shadcn@latest add @esuiss/field

Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add field

Manual

  1. Install the dependencies and add the cn helper:
$ pnpm add class-variance-authority
  1. Add the pieces it builds on: Label, Separator.

  2. Copy the source into components/ui/field.tsx:

components/ui/field.tsx
"use client"

import { cva, type VariantProps } from "class-variance-authority"
import { useId, useLayoutEffect, useMemo, useRef } from "react"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"

function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
  return (
    <fieldset
      data-slot="field-set"
      className={cn(
        "flex flex-col gap-6 has-[>[data-slot=radio-group]]:gap-3 has-[>[data-variant=inset]]:gap-2 has-[>[data-variant=inset]]:*:data-[slot=field-description]:ps-4 has-[>[data-variant=inset]]:*:data-[slot=field-description]:text-xs has-[>[data-variant=inset]]:*:data-[slot=field-legend]:mb-0 has-[>[data-variant=inset]]:*:data-[slot=field-legend]:ps-4 has-[>[data-variant=inset]]:*:data-[slot=field-legend]:text-xs has-[>[data-variant=inset]]:*:data-[slot=field-legend]:font-normal has-[>[data-variant=inset]]:*:data-[slot=field-legend]:text-muted-foreground has-[>[data-variant=inset]]:*:data-[slot=field-legend]:uppercase",
        className
      )}
  1. Update the import paths to match your project.

Usage

import { FieldSet, FieldLegend, FieldGroup, Field, FieldContent, FieldLabel, FieldTitle, FieldDescription, FieldSeparator, FieldError } from "@/components/ui/field"
<div className="w-96">
  <Field orientation="vertical">
    <FieldLabel htmlFor="field-username">Username</FieldLabel>
    <Input id="field-username" placeholder="janedoe" />
    <FieldDescription>
      Choose a unique name for your account.
    </FieldDescription>
  </Field>
</div>

Connect each FieldLabel to its control with htmlFor and id. Set orientation="horizontal" to place the label beside the control, for example for a checkbox or switch, or responsive to switch from vertical to horizontal when the surrounding FieldGroup is wide enough.

To show an error, set aria-invalid on the control and render a FieldError with children or an errors array. Pass data-disabled on a Field to dim its label when the control is disabled.

To build selectable cards, place a horizontal Field inside a FieldLabel; the label gets a border, and a highlighted ring when the control inside it is checked.

Examples

With help link

A description that contains a link, styled with text-primary and an underline.

import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export default function FieldWithHelpLink() {
  return (
    <div className="w-96">
      <Field orientation="vertical">
        <FieldLabel htmlFor="field-serial">Serial number</FieldLabel>
        <Input id="field-serial" placeholder="C02XL0GTJGH5" />
        <FieldDescription>
          Printed on the back of the device.{" "}
          <a href="#serial-help">Find your serial number</a>
        </FieldDescription>
      </Field>
    </div>
  )
}

Inset

Horizontal fields in an inset field group, forming a rounded settings list with separators.

import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"

export default function FieldInset() {
  return (
    <div className="w-96">
      <FieldSet className="w-96">
        <FieldLegend>Account</FieldLegend>
        <FieldGroup variant="inset">
          <Field orientation="horizontal">
            <FieldLabel htmlFor="inset-name">Name</FieldLabel>
            <Input id="inset-name" defaultValue="Jordan Lee" />
          </Field>
          <Field orientation="horizontal">
            <FieldLabel htmlFor="inset-email">Email</FieldLabel>
            <Input id="inset-email" defaultValue="[email protected]" />
          </Field>
          <Field orientation="horizontal">
            <FieldLabel htmlFor="inset-updates">Product updates</FieldLabel>
            <Switch id="inset-updates" defaultChecked />
          </Field>
        </FieldGroup>
        <FieldDescription>
          Your name and email appear on invoices.
        </FieldDescription>
      </FieldSet>
    </div>
  )
}

With textarea

A field that labels and describes a Textarea.

import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"

export default function FieldWithTextarea() {
  return (
    <div className="w-96">
      <Field orientation="vertical">
        <FieldLabel htmlFor="field-feedback">Feedback</FieldLabel>
        <Textarea
          id="field-feedback"
          placeholder="Tell us what you think..."
          rows={4}
        />
        <FieldDescription>Your feedback helps us improve.</FieldDescription>
      </Field>
    </div>
  )
}

Invalid

An invalid field with an error message announced as an alert.

import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export default function FieldInvalid() {
  return (
    <div className="w-96">
      <Field orientation="vertical" data-invalid>
        <FieldLabel htmlFor="field-email">Email</FieldLabel>
        <Input
          id="field-email"
          type="email"
          defaultValue="jane@"
          aria-invalid
        />
        <FieldError>Enter a valid email address.</FieldError>
      </Field>
    </div>
  )
}

Multiple errors

A FieldError that renders an errors array as a bulleted list.

import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export default function FieldMultipleErrors() {
  return (
    <div className="w-96">
      <Field orientation="vertical" data-invalid>
        <FieldLabel htmlFor="field-password">Password</FieldLabel>
        <Input
          id="field-password"
          type="password"
          defaultValue="abc"
          aria-invalid
        />
        <FieldError
          errors={[
            { message: "Password must be at least 8 characters." },
            { message: "Password must contain a number." },
          ]}
        />
      </Field>
    </div>
  )
}

Disabled

A disabled field with a dimmed label and a disabled input.

import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export default function FieldDisabled() {
  return (
    <div className="w-96">
      <Field orientation="vertical" data-disabled>
        <FieldLabel htmlFor="field-disabled">Workspace URL</FieldLabel>
        <Input id="field-disabled" defaultValue="acme.example.com" disabled />
        <FieldDescription>Contact an admin to change this.</FieldDescription>
      </Field>
    </div>
  )
}

Horizontal

Horizontal fields with a checkbox and a switch, one with a description in FieldContent.

import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"

export default function FieldHorizontal() {
  return (
    <div className="w-96">
      <FieldGroup>
        <Field orientation="horizontal">
          <Checkbox id="field-terms" defaultChecked />
          <FieldLabel htmlFor="field-terms">
            Accept terms and conditions
          </FieldLabel>
        </Field>
        <Field orientation="horizontal">
          <FieldContent>
            <FieldLabel htmlFor="field-marketing">Marketing emails</FieldLabel>
            <FieldDescription>
              Receive emails about new products and features.
            </FieldDescription>
          </FieldContent>
          <Switch id="field-marketing" />
        </Field>
      </FieldGroup>
    </div>
  )
}

Choice cards

Radio options presented as selectable cards by nesting a Field inside a FieldLabel.

import {
  Field,
  FieldContent,
  FieldDescription,
  FieldLabel,
  FieldLegend,
  FieldSet,
  FieldTitle,
} from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

export default function FieldChoiceCards() {
  return (
    <div className="w-96">
      <FieldSet>
        <FieldLegend variant="label">Plan</FieldLegend>
        <FieldDescription>
          Select the plan that fits your team.
        </FieldDescription>
        <RadioGroup defaultValue="pro" aria-label="Plan">
          <FieldLabel htmlFor="plan-starter">
            <Field orientation="horizontal">
              <FieldContent>
                <FieldTitle>Starter</FieldTitle>
                <FieldDescription>
                  For individuals and small projects.
                </FieldDescription>
              </FieldContent>
              <RadioGroupItem value="starter" id="plan-starter" />
            </Field>
          </FieldLabel>
          <FieldLabel htmlFor="plan-pro">
            <Field orientation="horizontal">
              <FieldContent>
                <FieldTitle>Pro</FieldTitle>
                <FieldDescription>
                  For growing teams that need more.
                </FieldDescription>
              </FieldContent>
              <RadioGroupItem value="pro" id="plan-pro" />
            </Field>
          </FieldLabel>
        </RadioGroup>
      </FieldSet>
    </div>
  )
}

Form

A complete payment form built from field sets, groups, a separator and actions.

import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"

export default function FieldForm() {
  return (
    <div className="w-96">
      <form onSubmit={(event) => event.preventDefault()}>
        <FieldGroup>
          <FieldSet>
            <FieldLegend>Payment method</FieldLegend>
            <FieldDescription>
              All transactions are secure and encrypted.
            </FieldDescription>
            <FieldGroup>
              <Field>
                <FieldLabel htmlFor="form-name">Name on card</FieldLabel>
                <Input id="form-name" placeholder="Jane Doe" required />
              </Field>
              <Field>
                <FieldLabel htmlFor="form-number">Card number</FieldLabel>
                <Input
                  id="form-number"
                  placeholder="1234 5678 9012 3456"
                  required
                />
                <FieldDescription>Enter your 16-digit number.</FieldDescription>
              </Field>
              <div className="grid grid-cols-2 gap-4">
                <Field>
                  <FieldLabel htmlFor="form-exp">Expiry</FieldLabel>
                  <Input id="form-exp" placeholder="MM/YY" />
                </Field>
                <Field>
                  <FieldLabel htmlFor="form-cvv">CVV</FieldLabel>
                  <Input id="form-cvv" placeholder="123" />
                </Field>
              </div>
            </FieldGroup>
          </FieldSet>
          <FieldSeparator />
          <FieldSet>
            <FieldLegend>Billing address</FieldLegend>
            <FieldGroup>
              <Field orientation="horizontal">
                <Checkbox id="form-same" defaultChecked />
                <FieldLabel htmlFor="form-same" className="font-normal">
                  Same as shipping address
                </FieldLabel>
              </Field>
            </FieldGroup>
          </FieldSet>
          <FieldSet>
            <FieldGroup>
              <Field>
                <FieldLabel htmlFor="form-notes">Notes</FieldLabel>
                <Textarea
                  id="form-notes"
                  placeholder="Add any additional comments"
                />
              </Field>
            </FieldGroup>
          </FieldSet>
          <Field orientation="horizontal">
            <Button type="submit">Submit</Button>
            <Button variant="outline" type="button">
              Cancel
            </Button>
          </Field>
        </FieldGroup>
      </form>
    </div>
  )
}

Separator with text

A FieldSeparator with text between a sign-in button and an email field.

import { Button } from "@/components/ui/button"
import {
  Field,
  FieldGroup,
  FieldLabel,
  FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export default function FieldSeparatorWithText() {
  return (
    <div className="w-96">
      <FieldGroup>
        <Field>
          <Button variant="outline">Continue with Apple</Button>
        </Field>
        <FieldSeparator>Or continue with</FieldSeparator>
        <Field>
          <FieldLabel htmlFor="sep-email">Email</FieldLabel>
          <Input id="sep-email" type="email" placeholder="[email protected]" />
        </Field>
      </FieldGroup>
    </div>
  )
}

API Reference

Anatomy26 parts
<Field>
<FieldLabel />
<FieldDescription />
<FieldError />
</Field>
<FieldSet>
<FieldLegend />
<FieldGroup>
<Field>
<FieldLabel />
</Field>
</FieldGroup>
<FieldDescription />
<FieldLabel>
<Field>
<FieldContent>
<FieldTitle />
<FieldDescription />
</FieldContent>
</Field>
</FieldLabel>
</FieldSet>
<FieldGroup>
<Field>
<FieldLabel />
<FieldContent>
<FieldLabel />
<FieldDescription />
</FieldContent>
<FieldDescription />
</Field>
<FieldSet>
<FieldLegend />
<FieldDescription />
</FieldSet>
<FieldSeparator />
</FieldGroup>
01

FieldSet

Groups related fields in a native fieldset.

Also accepts the props of React.ComponentProps<"fieldset">.

02

FieldLegend

The caption of a field set.

PropTypeDefaultDescription
variant"legend" | "label""legend"Sets data-variant to legend or label so the legend can be styled as a section caption or as a field label.

Also accepts the props of React.ComponentProps<"legend">.

03

FieldGroup

Stacks fields with consistent spacing, or groups them into an inset list.

PropTypeDefaultDescription
variant"default" | "inset""default"inset groups the fields into a rounded list on bg-muted, with separators between rows and inputs aligned to the end.

Also accepts the props of React.ComponentProps<"div">.

04

Field

Wraps a single control with its label, description and error.

PropTypeDefaultDescription
orientation"vertical" | "horizontal" | "responsive""vertical"vertical stacks the label above the control, horizontal places them side by side, and responsive switches to horizontal when the field group is wide enough.

Also accepts the props of React.ComponentProps<"div">.

05

FieldContent

Stacks a label and description next to a control in a horizontal field.

Also accepts the props of React.ComponentProps<"div">.

06

FieldLabel

The label of a field's control.

Also accepts the props of React.ComponentProps<typeof Label>.

07

FieldTitle

A heading inside a field that is not a label, such as the title of a choice card.

Also accepts the props of React.ComponentProps<"div">.

08

FieldDescription

Supporting text that describes the control.

Also accepts the props of React.ComponentProps<"p">.

09

FieldSeparator

A divider between sections, with optional centered text.

PropTypeDefaultDescription
childrenReact.ReactNode-Optional text centered on the separator line.

Also accepts the props of React.ComponentProps<"div">.

10

FieldError

An error message for the field.

PropTypeDefaultDescription
errorsArray<{ message?: string } | undefined>-A list of error objects to display. Duplicate messages are removed; one message renders as text and several render as a list. Ignored when children are provided.

Also accepts the props of React.ComponentProps<"div">.

Accessibility

  • Field has the group role and links its descriptions and errors to its first control through aria-describedby, updating the link when they change.
  • FieldSet renders a native fieldset, which takes its accessible name from FieldLegend.
  • FieldError has the alert role, so assistive technology announces it when it appears. It renders nothing when it has no children and no errors.
  • Set aria-invalid on the control to mark it invalid; FieldError does not set it for you.
  • Clicking a FieldLabel focuses or toggles its associated control.

Notes

  • Styling hooks: data-slot values field-set, field-legend, field-group, field, field-content, field-label (used by both FieldLabel and FieldTitle), field-description, field-separator and field-error.
  • Field sets data-orientation, and FieldGroup and FieldLegend set data-variant.
  • FieldGroup is a container query context named field-group, which drives the responsive orientation.
  • Uses the muted, border, muted-foreground, primary, destructive and accent colors.
iOS
Field on iOS in light modeField on iOS in dark mode
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function FieldExample() {
  return (
    <Field>
      <FieldLabel>Username</FieldLabel>
      <Input placeholder="evilrabbit" autoCapitalize="none" />
      <FieldDescription>
        Choose a unique username for your account.
      </FieldDescription>
    </Field>
  )
}
import EsuissUI
import SwiftUI

struct FieldDefaultDemo: View {
  @State private var username = ""

  var body: some View {
    Field {
      FieldLabel("Username")
        .accessibilityHidden(true)
      TextField("evilrabbit", text: $username)
        .textFieldStyle(.input)
        .textInputAutocapitalization(.never)
        .accessibilityLabel(Text("Username"))
      FieldDescription("Choose a unique username for your account.")
    }
  }
}
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import com.esuiss.ui.components.Field
import com.esuiss.ui.components.FieldDescription
import com.esuiss.ui.components.FieldLabel
import com.esuiss.ui.components.Input

val FieldPlainText = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false)

@Composable
fun FieldDefault() {
    var value by rememberSaveable { mutableStateOf("") }
    Field {
        FieldLabel("Username")
        Input(
            value,
            { value = it },
            Modifier.semantics { contentDescription = "Username" },
            placeholder = "evilrabbit",
            keyboardOptions = FieldPlainText,
        )
        FieldDescription("Choose a unique username for your account.")
    }
}

Field wraps one control, such as an Input, Textarea or Switch, with a FieldLabel, an optional FieldDescription and a FieldError. It shares the field's disabled and invalid state with the control and handles validation: required values, length limits and custom validate functions, checked on submit, blur or change.

FieldSet and FieldLegend group related fields under a heading, and FieldGroup stacks fields. The inset variant of FieldGroup turns the fields into rows on a rounded bg-muted surface with hairlines between them, like a settings list.

Installation

$ pnpm dlx shadcn@latest add @esuiss-rn/field

See Installation for the components.json setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add field
$ pnpm dlx @esuiss/ui@latest add field --platform swift
$ pnpm dlx @esuiss/ui@latest add field --platform kotlin

The CLI also adds what it builds on: Separator.

Manual

  1. Install the dependencies:
npx expo install cn
  1. Add the pieces it builds on: Separator, and the primitives components/primitives/field.tsx, components/primitives/fieldset.tsx, components/primitives/use-render.tsx.

  2. Copy the source into components/ui/field.tsx:

uniwind/components/ui/field.tsx
import { cn } from "cn"
import * as React from "react"
import { Text as RNText, View } from "react-native"
import * as FieldPrimitive from "@/components/primitives/field"
import * as FieldsetPrimitive from "@/components/primitives/fieldset"
import { resolveClassName } from "@/components/primitives/use-render"
import { Separator } from "@/components/ui/separator"

type FieldGroupVariant = "default" | "inset"

type FieldOrientation = "vertical" | "horizontal" | "responsive"

interface FieldGroupItemContextValue {
  variant: FieldGroupVariant
  index: number
}
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn
  1. Add the pieces it builds on: Separator, and the primitives components/primitives/field.tsx, components/primitives/fieldset.tsx, components/primitives/use-render.tsx.

  2. Copy the source into components/ui/field.tsx:

nativewind/components/ui/field.tsx
import { cn } from "cn"
import * as React from "react"
import { Text as RNText, View } from "react-native"
import * as FieldPrimitive from "@/components/primitives/field"
import * as FieldsetPrimitive from "@/components/primitives/fieldset"
import { resolveClassName } from "@/components/primitives/use-render"
import { Separator } from "@/components/ui/separator"

type FieldGroupVariant = "default" | "inset"

type FieldOrientation = "vertical" | "horizontal" | "responsive"

interface FieldGroupItemContextValue {
  variant: FieldGroupVariant
  index: number
}
  1. Update the import paths to match your project.
  1. Add the pieces it builds on: Separator, and the primitives components/primitives/field.tsx, components/primitives/fieldset.tsx, components/primitives/use-render.tsx.

  2. Copy the source into components/ui/field.tsx:

stylesheet/components/ui/field.tsx
import * as React from "react"
import {
  Text as RNText,
  StyleSheet,
  useWindowDimensions,
  View,
} from "react-native"
import * as FieldPrimitive from "@/components/primitives/field"
import * as FieldsetPrimitive from "@/components/primitives/fieldset"
import { Separator } from "@/components/ui/separator"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

type FieldGroupVariant = "default" | "inset"

type FieldOrientation = "vertical" | "horizontal" | "responsive"
  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Copy the theme files every view uses:

swift/Theme/Theme.swift
import SwiftUI
import UIKit

struct RGBA: Sendable {
  let red: CGFloat
  let green: CGFloat
  let blue: CGFloat
  let alpha: CGFloat

  init(_ hex: UInt32, _ alpha: CGFloat = 1) {
    red = CGFloat((hex >> 16) & 0xFF) / 255
    green = CGFloat((hex >> 8) & 0xFF) / 255
    blue = CGFloat(hex & 0xFF) / 255
    self.alpha = alpha
  }
swift/Theme/Tokens.swift
import SwiftUI

public enum EsuissColor {
  public static let background = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let foreground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let card = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let cardForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let popover = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let popoverForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let primary = Color.adaptive(light: .init(0x0071E3), dark: .init(0x2997FF))
  public static let primaryForeground = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let secondary = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let secondaryForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let muted = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let mutedForeground = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let accent = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  1. Copy the source and every file it uses:
swift/Components/Field.swift
import SwiftUI

extension EnvironmentValues {
  @Entry public var isInvalid = false
  @Entry var fieldInset = false
  @Entry var fieldGroupInset = false
  @Entry var fieldSetInset = false
}

extension View {
  public func invalid(_ isInvalid: Bool = true) -> some View {
    environment(\.isInvalid, isInvalid)
  }
}

public enum FieldOrientation: CaseIterable, Sendable {
swift/Components/Separator.swift
import SwiftUI

public struct Separator: View {
  let orientation: Axis

  public init(_ orientation: Axis = .horizontal) {
    self.orientation = orientation
  }

  public var body: some View {
    Rectangle()
      .fill(EsuissColor.border)
      .frame(width: orientation == .vertical ? 1 : nil, height: orientation == .horizontal ? 1 : nil)
      .frame(maxWidth: orientation == .horizontal ? .infinity : nil, maxHeight: orientation == .vertical ? .infinity : nil)
      .accessibilityHidden(true)
  }
swift/Components/TextVariant.swift
import SwiftUI

public enum TextVariant: CaseIterable, Sendable {
  case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted

  var style: TextStyle {
    switch self {
    case .h1: .xl3
    case .h2: .xl2
    case .h3: .xl
    case .h4, .lead, .large: .lg
    case .code, .small, .muted: .sm
    default: .base
    }
  }
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
build.gradle.kts
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
package com.esuiss.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
kotlin/theme/Tokens.kt
package com.esuiss.ui.theme

import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

@Immutable
data class Colors(
    val background: Color,
    val foreground: Color,
    val card: Color,
    val cardForeground: Color,
    val popover: Color,
    val popoverForeground: Color,
    val primary: Color,
    val primaryForeground: Color,
  1. Copy the source and every file it uses:
kotlin/components/Field.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.LayoutScopeMarker
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
kotlin/components/Content.kt
package com.esuiss.ui.components

import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

val LocalIconSize = compositionLocalOf { 20.dp }
kotlin/components/FormSupport.kt
package com.esuiss.ui.components

import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
kotlin/components/Label.kt
package com.esuiss.ui.components

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
kotlin/components/Separator.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme

@Composable
fun Separator(
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { FieldSet, FieldLegend, FieldGroup, Field, FieldContent, FieldLabel, FieldTitle, FieldDescription, FieldSeparator, FieldError } from "@/components/ui/field"
<Field>
  <FieldLabel>Username</FieldLabel>
  <Input placeholder="evilrabbit" autoCapitalize="none" />
  <FieldDescription>
    Choose a unique username for your account.
  </FieldDescription>
</Field>

Put a FieldLabel, the control and a FieldError inside Field. The control is labelled by the label and uses the description and error text as its accessibility hint. Set invalid to force the error state, or let validation decide it.

FieldError shows its children, or the messages from errors (for example from a form library), and can be limited to one failure with match, such as tooShort. Use orientation="horizontal" for a label beside a switch or checkbox, with FieldContent holding a FieldTitle and FieldDescription.

FieldSeparator divides fields and can show short text such as "or" in the middle.

Examples

Invalid

A field in the invalid state with an error message.

iOS
Field (invalid) on iOS in light modeField (invalid) on iOS in dark mode
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function FieldInvalidExample() {
  return (
    <Field invalid>
      <FieldLabel>Email</FieldLabel>
      <Input
        defaultValue="hello@"
        keyboardType="email-address"
        autoCapitalize="none"
      />
      <FieldDescription>
        We will send a confirmation link here.
      </FieldDescription>
      <FieldError>Enter a valid email address.</FieldError>
    </Field>
  )
}
import EsuissUI
import SwiftUI

struct FieldInvalidDemo: View {
  @State private var email = "hello@"

  var body: some View {
    Field(invalid: true) {
      FieldLabel("Email")
        .accessibilityHidden(true)
      TextField("Email", text: $email)
        .textFieldStyle(.input)
        .keyboardType(.emailAddress)
        .textInputAutocapitalization(.never)
        .accessibilityLabel(Text("Email"))
      FieldDescription("We will send a confirmation link here.")
      FieldError("Enter a valid email address.")
    }
  }
}
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import com.esuiss.ui.components.Field
import com.esuiss.ui.components.FieldDescription
import com.esuiss.ui.components.FieldError
import com.esuiss.ui.components.FieldLabel
import com.esuiss.ui.components.Input

val FieldEmailText = KeyboardOptions(
    capitalization = KeyboardCapitalization.None,
    autoCorrectEnabled = false,
    keyboardType = KeyboardType.Email,
)

@Composable
fun FieldInvalid() {
    var value by rememberSaveable { mutableStateOf("hello@") }
    Field(invalid = true) {
        FieldLabel("Email")
        Input(value, { value = it }, Modifier.semantics { contentDescription = "Email" }, keyboardOptions = FieldEmailText)
        FieldDescription("We will send a confirmation link here.")
        FieldError("Enter a valid email address.")
    }
}

Validation

Required, custom and minimum-length validation on blur and on change.

iOS
Field (validation) on iOS in light modeField (validation) on iOS in dark mode
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"

export function FieldValidationExample() {
  return (
    <FieldGroup>
      <Field validationMode="onBlur">
        <FieldLabel>Name</FieldLabel>
        <Input placeholder="Required" required />
        <FieldError />
      </Field>
      <Field
        validationMode="onChange"
        validate={(value) =>
          typeof value === "string" && value.includes("@")
            ? null
            : "Email must contain @."
        }
      >
        <FieldLabel>Email</FieldLabel>
        <Input
          placeholder="[email protected]"
          keyboardType="email-address"
          autoCapitalize="none"
        />
        <FieldDescription>Validated on every change.</FieldDescription>
        <FieldError />
      </Field>
      <Field validationMode="onBlur">
        <FieldLabel>Bio</FieldLabel>
        <Textarea placeholder="At least 20 characters" minLength={20} />
        <FieldError match="tooShort">Tell us a little more.</FieldError>
      </Field>
    </FieldGroup>
  )
}
import EsuissUI
import SwiftUI

enum FieldMessages {
  static let required = "This field is required."

  static func tooShort(_ minLength: Int) -> String {
    "Use at least \(minLength) characters."
  }
}

struct FieldValidationDemo: View {
  enum Focus: Hashable {
    case name, email, bio
  }

  @State private var name = ""
  @State private var email = ""
  @State private var bio = ""
  @State private var nameError: String?
  @State private var emailError: String?
  @State private var bioError: String?
  @State private var nameTouched = false
  @State private var bioTouched = false
  @FocusState private var focus: Focus?

  var body: some View {
    FieldGroup {
      Field(invalid: nameError != nil) {
        FieldLabel("Name")
          .accessibilityHidden(true)
        TextField("Required", text: $name)
          .textFieldStyle(.input)
          .focused($focus, equals: .name)
          .accessibilityLabel(Text("Name"))
        FieldError(errors: [nameError])
      }
      Field(invalid: emailError != nil) {
        FieldLabel("Email")
          .accessibilityHidden(true)
        TextField("[email protected]", text: $email)
          .textFieldStyle(.input)
          .keyboardType(.emailAddress)
          .textInputAutocapitalization(.never)
          .focused($focus, equals: .email)
          .accessibilityLabel(Text("Email"))
        FieldDescription("Validated on every change.")
        FieldError(errors: [emailError])
      }
      Field(invalid: bioError != nil) {
        FieldLabel("Bio")
          .accessibilityHidden(true)
        TextField("At least 20 characters", text: $bio, axis: .vertical)
          .textFieldStyle(.textarea)
          .focused($focus, equals: .bio)
          .accessibilityLabel(Text("Bio"))
        if bioError != nil {
          FieldError("Tell us a little more.")
        }
      }
    }
    .onChange(of: focus) { previous, _ in
      switch previous {
      case .name:
        nameTouched = true
        nameError = validateName()
      case .bio:
        bioTouched = true
        bioError = validateBio()
      default:
        break
      }
    }
    .onChange(of: name) {
      if nameTouched { nameError = validateName() }
    }
    .onChange(of: email) {
      emailError = email.contains("@") ? nil : "Email must contain @."
    }
    .onChange(of: bio) {
      if bioTouched { bioError = validateBio() }
    }
  }

  func validateName() -> String? {
    name.isEmpty ? FieldMessages.required : nil
  }

  func validateBio() -> String? {
    !bio.isEmpty && bio.count < 20 ? FieldMessages.tooShort(20) : nil
  }
}

let required = "This field is required."

func tooShort(_ minLength: Int) -> String {
  "Use at least \(minLength) characters."
}
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import com.esuiss.ui.components.Field
import com.esuiss.ui.components.FieldDescription
import com.esuiss.ui.components.FieldError
import com.esuiss.ui.components.FieldGroup
import com.esuiss.ui.components.FieldLabel
import com.esuiss.ui.components.Input
import com.esuiss.ui.components.Textarea

val FieldEmailText = KeyboardOptions(
    capitalization = KeyboardCapitalization.None,
    autoCorrectEnabled = false,
    keyboardType = KeyboardType.Email,
)

class FieldFocus {
    var focused = false
}

@Composable
fun fieldOnBlur(onBlur: () -> Unit): Modifier {
    val focus = remember { FieldFocus() }
    val current by rememberUpdatedState(onBlur)
    return Modifier.onFocusChanged { state ->
        if (focus.focused && !state.isFocused) current()
        focus.focused = state.isFocused
    }
}

@Composable
fun FieldValidation() {
    var name by rememberSaveable { mutableStateOf("") }
    var nameError by rememberSaveable { mutableStateOf<String?>(null) }
    var email by rememberSaveable { mutableStateOf("") }
    var emailError by rememberSaveable { mutableStateOf<String?>(null) }
    var bio by rememberSaveable { mutableStateOf("") }
    var bioShort by rememberSaveable { mutableStateOf(false) }
    FieldGroup {
        Field(invalid = nameError != null) {
            FieldLabel("Name")
            Input(
                name,
                {
                    name = it
                    if (nameError != null && it.isNotEmpty()) nameError = null
                },
                Modifier
                    .semantics { contentDescription = "Name" }
                    .then(fieldOnBlur { nameError = if (name.isEmpty()) FieldRequiredMessage else null }),
                placeholder = "Required",
            )
            FieldError(nameError)
        }
        Field(invalid = emailError != null) {
            FieldLabel("Email")
            Input(
                email,
                {
                    email = it
                    emailError = if (it.contains("@")) null else "Email must contain @."
                },
                Modifier.semantics { contentDescription = "Email" },
                placeholder = "[email protected]",
                keyboardOptions = FieldEmailText,
            )
            FieldDescription("Validated on every change.")
            FieldError(emailError)
        }
        Field(invalid = bioShort) {
            FieldLabel("Bio")
            Textarea(
                bio,
                {
                    bio = it
                    if (bioShort && (it.isEmpty() || it.length >= 20)) bioShort = false
                },
                Modifier
                    .semantics { contentDescription = "Bio" }
                    .then(fieldOnBlur { bioShort = bio.isNotEmpty() && bio.length < 20 }),
                placeholder = "At least 20 characters",
            )
            FieldError(if (bioShort) "Tell us a little more." else null)
        }
    }
}

Disabled

A disabled field.

iOS
Field (disabled) on iOS in light modeField (disabled) on iOS in dark mode
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function FieldDisabledExample() {
  return (
    <Field disabled>
      <FieldLabel>Workspace</FieldLabel>
      <Input defaultValue="acme" />
      <FieldDescription>Contact an admin to change this.</FieldDescription>
    </Field>
  )
}
import EsuissUI
import SwiftUI

struct FieldDisabledDemo: View {
  @State private var workspace = "acme"

  var body: some View {
    Field {
      FieldLabel("Workspace")
        .accessibilityHidden(true)
      TextField("Workspace", text: $workspace)
        .textFieldStyle(.input)
        .accessibilityLabel(Text("Workspace"))
      FieldDescription("Contact an admin to change this.")
    }
    .disabled(true)
  }
}
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import com.esuiss.ui.components.Field
import com.esuiss.ui.components.FieldDescription
import com.esuiss.ui.components.FieldLabel
import com.esuiss.ui.components.Input

@Composable
fun FieldDisabled() {
    var value by rememberSaveable { mutableStateOf("acme") }
    Field(enabled = false) {
        FieldLabel("Workspace")
        Input(value, { value = it }, Modifier.semantics { contentDescription = "Workspace" })
        FieldDescription("Contact an admin to change this.")
    }
}

Horizontal

Switch rows with a title and description beside the control.

iOS
Field (horizontal) on iOS in light modeField (horizontal) on iOS in dark mode
import { Field, FieldContent, FieldDescription, FieldGroup, FieldSeparator, FieldTitle } from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"

export function FieldHorizontalExample() {
  return (
    <FieldGroup>
      <Field orientation="horizontal">
        <FieldContent>
          <FieldTitle>Marketing emails</FieldTitle>
          <FieldDescription>
            Receive emails about new products.
          </FieldDescription>
        </FieldContent>
        <Switch />
      </Field>
      <FieldSeparator />
      <Field orientation="horizontal">
        <FieldContent>
          <FieldTitle>Security emails</FieldTitle>
          <FieldDescription>
            Receive emails about your account.
          </FieldDescription>
        </FieldContent>
        <Switch />
      </Field>
    </FieldGroup>
  )
}
import EsuissUI
import SwiftUI

struct FieldHorizontalDemo: View {
  @State private var marketing = false
  @State private var security = false

  var body: some View {
    FieldGroup {
      Field(orientation: .horizontal) {
        FieldContent {
          FieldTitle("Marketing emails")
          FieldDescription("Receive emails about new products.")
        }
        Toggle(isOn: $marketing) { EmptyView() }
          .toggleStyle(.switch(size: .default))
          .accessibilityLabel(Text("Marketing emails"))
      }
      FieldSeparator()
      Field(orientation: .horizontal) {
        FieldContent {
          FieldTitle("Security emails")
          FieldDescription("Receive emails about your account.")
        }
        Toggle(isOn: $security) { EmptyView() }
          .toggleStyle(.switch(size: .default))
          .accessibilityLabel(Text("Security emails"))
      }
    }
  }
}
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import com.esuiss.ui.components.Field
import com.esuiss.ui.components.FieldContent
import com.esuiss.ui.components.FieldDescription
import com.esuiss.ui.components.FieldGroup
import com.esuiss.ui.components.FieldOrientation
import com.esuiss.ui.components.FieldSeparator
import com.esuiss.ui.components.FieldTitle
import com.esuiss.ui.components.Switch

@Composable
fun FieldHorizontal() {
    var marketing by rememberSaveable { mutableStateOf(false) }
    var security by rememberSaveable { mutableStateOf(false) }
    FieldGroup {
        Field(orientation = FieldOrientation.Horizontal) {
            FieldContent {
                FieldTitle("Marketing emails")
                FieldDescription("Receive emails about new products.")
            }
            Switch(marketing, { marketing = it }, Modifier.semantics { contentDescription = "Marketing emails" })
        }
        FieldSeparator()
        Field(orientation = FieldOrientation.Horizontal) {
            FieldContent {
                FieldTitle("Security emails")
                FieldDescription("Receive emails about your account.")
            }
            Switch(security, { security = it }, Modifier.semantics { contentDescription = "Security emails" })
        }
    }
}

Checkbox group

A fieldset of checkboxes with a legend.

iOS
Field (checkbox group) on iOS in light modeField (checkbox group) on iOS in dark mode
import * as React from "react"
import { View } from "react-native"
import * as CheckboxGroupPrimitive from "@/components/primitives/checkbox-group"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldDescription, FieldLegend, FieldSet } from "@/components/ui/field"
import { Text } from "@/components/ui/text"

const TOPPINGS = ["cheese", "olives", "peppers"]

function ToppingsDemo() {
  const [value, setValue] = React.useState<string[]>(["cheese"])
  return (
    <FieldSet>
      <FieldLegend>Toppings</FieldLegend>
      <FieldDescription>Choose what goes on your pizza.</FieldDescription>
      <CheckboxGroupPrimitive.Root
        value={value}
        onValueChange={setValue}
        allValues={TOPPINGS}
        className="gap-3"
      >
        <View className="flex-row items-center gap-3">
          <Checkbox parent />
          <Text>All toppings</Text>
        </View>
        {TOPPINGS.map((topping) => (
          <View key={topping} className="flex-row items-center gap-3 ps-6">
            <Checkbox value={topping} />
            <Text className="capitalize">{topping}</Text>
          </View>
        ))}
      </CheckboxGroupPrimitive.Root>
    </FieldSet>
  )
}

export function FieldCheckboxGroupExample() {
  return (
    <ToppingsDemo />
  )
}
import EsuissUI
import SwiftUI

struct FieldToppingsDemo: View {
  private static let toppings = ["cheese", "olives", "peppers"]

  @State private var selected: Set<String> = ["cheese"]

  var body: some View {
    FieldSet {
      FieldLegend("Toppings")
      FieldDescription("Choose what goes on your pizza.")
      VStack(alignment: .leading, spacing: 12) {
        Toggle(sources: Self.toppings.map(binding), isOn: \.self) {
          Text("All toppings")
            .textStyle(.base)
            .foregroundStyle(EsuissColor.foreground)
        }
        .toggleStyle(.checkbox)
        ForEach(Self.toppings, id: \.self) { topping in
          Toggle(isOn: binding(topping)) {
            Text(verbatim: topping.capitalized)
              .textStyle(.base)
              .foregroundStyle(EsuissColor.foreground)
          }
          .toggleStyle(.checkbox)
          .padding(.leading, 24)
        }
      }
    }
  }

  func binding(_ topping: String) -> Binding<Bool> {
    Binding(
      get: { selected.contains(topping) },
      set: { isOn in
        if isOn { selected.insert(topping) } else { selected.remove(topping) }
      }
    )
  }
}

let toppings = ["cheese", "olives", "peppers"]
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Checkbox
import com.esuiss.ui.components.FieldDescription
import com.esuiss.ui.components.FieldLegend
import com.esuiss.ui.components.FieldSet
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TriStateCheckbox

val FieldToppings = listOf("cheese", "olives", "peppers")

@Composable
fun FieldCheckboxGroup() {
    var value by rememberSaveable { mutableStateOf(listOf("cheese")) }
    val parent = when (value.size) {
        0 -> ToggleableState.Off
        FieldToppings.size -> ToggleableState.On
        else -> ToggleableState.Indeterminate
    }
    FieldSet {
        FieldLegend("Toppings")
        FieldDescription("Choose what goes on your pizza.")
        Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
            Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
                TriStateCheckbox(
                    parent,
                    { value = if (parent == ToggleableState.On) emptyList() else FieldToppings },
                    Modifier.semantics { contentDescription = "All toppings" },
                )
                Text("All toppings")
            }
            FieldToppings.forEach { topping ->
                val label = topping.replaceFirstChar { it.uppercaseChar() }
                Row(
                    modifier = Modifier.padding(start = 24.dp),
                    horizontalArrangement = Arrangement.spacedBy(12.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    Checkbox(
                        topping in value,
                        { checked -> value = if (checked) FieldToppings.filter { it in value || it == topping } else value - topping },
                        Modifier.semantics { contentDescription = label },
                    )
                    Text(label)
                }
            }
        }
    }
}

Inset

An inset group of rows on a rounded surface.

iOS
Field (inset) on iOS in light modeField (inset) on iOS in dark mode
import { Field, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function FieldInsetExample() {
  return (
    <FieldSet>
      <FieldLegend>Profile</FieldLegend>
      <FieldGroup variant="inset">
        <Field orientation="horizontal">
          <FieldLabel className="flex-1 font-normal">Name</FieldLabel>
          <Input
            className="h-8 flex-1 bg-transparent px-0 text-right"
            defaultValue="Ada Lovelace"
          />
        </Field>
        <Field orientation="horizontal">
          <FieldLabel className="flex-1 font-normal">City</FieldLabel>
          <Input
            className="h-8 flex-1 bg-transparent px-0 text-right"
            defaultValue="London"
          />
        </Field>
      </FieldGroup>
    </FieldSet>
  )
}
import EsuissUI
import SwiftUI

struct FieldInsetDemo: View {
  @State private var name = "Ada Lovelace"
  @State private var city = "London"

  var body: some View {
    FieldSet {
      FieldLegend("Profile")
      FieldGroup(.inset) {
        FieldInsetRow(title: "Name", text: $name)
        FieldInsetRow(title: "City", text: $city)
      }
    }
  }
}

struct FieldInsetRow: View {
  let title: LocalizedStringKey
  @Binding var text: String

  var body: some View {
    Field(orientation: .horizontal) {
      FieldLabel(title)
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityHidden(true)
      TextField(title, text: $text)
        .textFieldStyle(.plain)
        .multilineTextAlignment(.trailing)
        .textStyle(.base)
        .foregroundStyle(EsuissColor.foreground)
        .frame(maxWidth: .infinity, minHeight: 32)
        .accessibilityLabel(Text(title))
    }
  }
}
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Field
import com.esuiss.ui.components.FieldGroup
import com.esuiss.ui.components.FieldGroupVariant
import com.esuiss.ui.components.FieldLabel
import com.esuiss.ui.components.FieldLegend
import com.esuiss.ui.components.FieldOrientation
import com.esuiss.ui.components.FieldSet
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun FieldInsetInput(value: String, onValueChange: (String) -> Unit, label: String, modifier: Modifier) {
    val colors = EsuissTheme.colors
    BasicTextField(
        value = value,
        onValueChange = onValueChange,
        modifier = modifier
            .height(32.dp)
            .semantics { contentDescription = label },
        textStyle = Typography.base.copy(color = colors.foreground, textAlign = TextAlign.End),
        singleLine = true,
        cursorBrush = SolidColor(colors.primary),
        decorationBox = { inner ->
            Row(verticalAlignment = Alignment.CenterVertically) { inner() }
        },
    )
}

@Composable
fun FieldInset() {
    var name by rememberSaveable { mutableStateOf("Ada Lovelace") }
    var city by rememberSaveable { mutableStateOf("London") }
    FieldSet {
        FieldLegend("Profile")
        FieldGroup(variant = FieldGroupVariant.Inset) {
            Field(orientation = FieldOrientation.Horizontal) {
                FieldLabel("Name", Modifier.weight(1f))
                FieldInsetInput(name, { name = it }, "Name", Modifier.weight(1f))
            }
            Field(orientation = FieldOrientation.Horizontal) {
                FieldLabel("City", Modifier.weight(1f))
                FieldInsetInput(city, { city = it }, "City", Modifier.weight(1f))
            }
        }
    }
}

Form

A complete form with validation and a submit button.

iOS
Field (form) on iOS in light modeField (form) on iOS in dark mode
import * as React from "react"
import * as FormPrimitive from "@/components/primitives/form"
import { Button } from "@/components/ui/button"
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Text } from "@/components/ui/text"

const DIGIT = /\d/

function FormDemo() {
  const actionsRef = React.useRef<FormPrimitive.Actions | null>(null)
  const [errors, setErrors] = React.useState<FormPrimitive.Errors>({})
  const [submitted, setSubmitted] = React.useState<string | null>(null)

  return (
    <FormPrimitive.Root
      actionsRef={actionsRef}
      errors={errors}
      onFormSubmit={(values) => {
        if (values.username === "admin") {
          setErrors({ username: "This username is taken." })
          return
        }
        setSubmitted(JSON.stringify(values))
      }}
      className="gap-6"
    >
      <FieldGroup>
        <Field name="username">
          <FieldLabel>Username</FieldLabel>
          <Input placeholder="Try admin" autoCapitalize="none" required />
          <FieldDescription>Letters and numbers only.</FieldDescription>
          <FieldError />
        </Field>
        <Field
          name="password"
          validate={(value) =>
            typeof value === "string" && DIGIT.test(value)
              ? null
              : "Include at least one number."
          }
        >
          <FieldLabel>Password</FieldLabel>
          <Input secureTextEntry required minLength={8} />
          <FieldError />
        </Field>
      </FieldGroup>
      <Button onPress={() => actionsRef.current?.submit()}>
        <Text>Create account</Text>
      </Button>
      {submitted ? <Text variant="muted">Submitted: {submitted}</Text> : null}
    </FormPrimitive.Root>
  )
}

export function FieldFormExample() {
  return (
    <FormDemo />
  )
}
import EsuissUI
import SwiftUI

enum FieldMessages {
  static let required = "This field is required."

  static func tooShort(_ minLength: Int) -> String {
    "Use at least \(minLength) characters."
  }
}

struct FieldFormDemo: View {
  @State private var username = ""
  @State private var password = ""
  @State private var usernameErrors: [String] = []
  @State private var passwordErrors: [String] = []
  @State private var taken = false
  @State private var submitted: String?

  var body: some View {
    VStack(alignment: .leading, spacing: 24) {
      FieldGroup {
        Field(invalid: !usernameErrors.isEmpty || taken) {
          FieldLabel("Username")
            .accessibilityHidden(true)
          TextField("Try admin", text: $username)
            .textFieldStyle(.input)
            .textInputAutocapitalization(.never)
            .autocorrectionDisabled()
            .accessibilityLabel(Text("Username"))
          FieldDescription("Letters and numbers only.")
          FieldError(errors: usernameErrors + (taken ? ["This username is taken."] : []))
        }
        Field(invalid: !passwordErrors.isEmpty) {
          FieldLabel("Password")
            .accessibilityHidden(true)
          SecureField("Password", text: $password)
            .textFieldStyle(.input)
            .accessibilityLabel(Text("Password"))
          FieldError(errors: passwordErrors)
        }
      }
      Button {
        submit()
      } label: {
        Text("Create account").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant())
      if let submitted {
        Text(verbatim: "Submitted: \(submitted)")
          .textVariant(.muted)
      }
    }
    .onChange(of: username) { taken = false }
  }

  func submit() {
    usernameErrors = username.isEmpty ? [FieldMessages.required] : []
    var errors: [String] = []
    if password.isEmpty {
      errors.append(FieldMessages.required)
    } else if password.count < 8 {
      errors.append(FieldMessages.tooShort(8))
    }
    if !password.contains(where: \.isNumber) {
      errors.append("Include at least one number.")
    }
    passwordErrors = errors
    guard usernameErrors.isEmpty, passwordErrors.isEmpty else { return }
    if username == "admin" {
      taken = true
      return
    }
    submitted = "{\"username\":\"\(username)\",\"password\":\"\(password)\"}"
  }
}

let required = "This field is required."

func tooShort(_ minLength: Int) -> String {
  "Use at least \(minLength) characters."
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.Field
import com.esuiss.ui.components.FieldDescription
import com.esuiss.ui.components.FieldError
import com.esuiss.ui.components.FieldGroup
import com.esuiss.ui.components.FieldLabel
import com.esuiss.ui.components.Input
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant

val FieldPlainText = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false)

fun fieldUsernameErrors(username: String, taken: Boolean): List<String> = buildList {
    if (username.isEmpty()) add(FieldRequiredMessage)
    if (taken) add("This username is taken.")
}

fun fieldPasswordErrors(password: String): List<String> = buildList {
    if (password.isEmpty()) add(FieldRequiredMessage)
    if (password.isNotEmpty() && password.length < 8) add("Use at least 8 characters.")
    if (!password.any { it.isDigit() }) add("Include at least one number.")
}

@Composable
fun FieldForm() {
    var username by rememberSaveable { mutableStateOf("") }
    var password by rememberSaveable { mutableStateOf("") }
    var taken by rememberSaveable { mutableStateOf(false) }
    var attempted by rememberSaveable { mutableStateOf(false) }
    var submitted by rememberSaveable { mutableStateOf<String?>(null) }
    val usernameErrors = if (attempted) fieldUsernameErrors(username, taken) else emptyList()
    val passwordErrors = if (attempted) fieldPasswordErrors(password) else emptyList()
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(24.dp)) {
        FieldGroup {
            Field(invalid = usernameErrors.isNotEmpty()) {
                FieldLabel("Username")
                Input(
                    username,
                    {
                        username = it
                        taken = false
                    },
                    Modifier.semantics { contentDescription = "Username" },
                    placeholder = "Try admin",
                    keyboardOptions = FieldPlainText,
                )
                FieldDescription("Letters and numbers only.")
                FieldError(usernameErrors)
            }
            Field(invalid = passwordErrors.isNotEmpty()) {
                FieldLabel("Password")
                Input(
                    password,
                    { password = it },
                    Modifier.semantics { contentDescription = "Password" },
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
                    visualTransformation = PasswordVisualTransformation(),
                )
                FieldError(passwordErrors)
            }
        }
        Button(
            "Create account",
            onClick = {
                attempted = true
                val invalid = fieldUsernameErrors(username, false).isNotEmpty() || fieldPasswordErrors(password).isNotEmpty()
                if (invalid) return@Button
                if (username == "admin") {
                    taken = true
                    return@Button
                }
                submitted = "{\"username\":\"$username\",\"password\":\"$password\"}"
            },
            modifier = Modifier.fillMaxWidth(),
        )
        submitted?.let { Text("Submitted: $it", variant = TextVariant.Muted) }
    }
}

API Reference

01

FieldSet

Group of related fields.

Also accepts the props of React.ComponentProps<typeof FieldsetPrimitive.Root>.

02

FieldLegend

Heading for a fieldset.

PropTypeDefaultDescription
variant"legend" | "label""legend"legend (heading) or label.

Also accepts the props of React.ComponentProps<typeof FieldsetPrimitive.Legend>.

03

FieldGroup

Stack of fields, plain or inset.

PropTypeDefaultDescription
variantFieldGroupVariant"default"default or inset.

Also accepts the props of React.ComponentProps<typeof View>.

04

Field

One field that shares state with its control.

PropTypeDefaultDescription
orientationFieldOrientation"vertical"vertical, horizontal or responsive.

Also accepts the props of React.ComponentProps<typeof FieldPrimitive.Root>.

05

FieldContent

Column for a title and description beside a control.

Also accepts the props of React.ComponentProps<typeof View>.

06

FieldLabel

Label linked to the control.

Also accepts the props of React.ComponentProps<typeof FieldPrimitive.Label>.

07

FieldTitle

Bold text for a field's title.

Also accepts the props of React.ComponentProps<typeof RNText>.

08

FieldDescription

Helper text.

Also accepts the props of React.ComponentProps<typeof FieldPrimitive.Description>.

09

FieldSeparator

Divider with optional centered text.

PropTypeDefaultDescription
childrenReact.ReactNode-

Also accepts the props of React.ComponentProps<typeof View>.

10

FieldError

Error message shown when validation fails.

PropTypeDefaultDescription
errorsArray<{ message?: string } | undefined>-Array of { message } objects to show.

Also accepts the props of React.ComponentProps<typeof FieldPrimitive.Error>.

Accessibility

  • The control is labelled by FieldLabel and gets the description and error text as its accessibility hint.
  • FieldError has the alert role, so screen readers announce it when it appears.
  • FieldSet has the group role labelled by its legend; the legend has the header role.
  • Disabled state is passed to the label and control.

Notes

  • Uses text-foreground, text-muted-foreground, text-destructive and bg-muted tokens; light and dark follow the system appearance.
  • Inset separators use start and end offsets, so they flip in right-to-left layouts.
NextFullscreen MenuPreviousFAB

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • With help link
    • Inset
    • With textarea
    • Invalid
    • Multiple errors
    • Disabled
    • Horizontal
    • Choice cards
    • Form
    • Separator with text
  4. 04API Reference
    • FieldSet
    • FieldLegend
    • FieldGroup
    • Field
    • FieldContent
    • FieldLabel
    • FieldTitle
    • FieldDescription
    • FieldSeparator
    • FieldError
  5. 05Accessibility
  6. 06Notes
  7. 07Installation
    • Manual
  8. 08Usage
  9. 09Examples
    • Invalid
    • Validation
    • Disabled
    • Horizontal
    • Checkbox group
    • Inset
    • Form
  10. 10API Reference
    • FieldSet
    • FieldLegend
    • FieldGroup
    • Field
    • FieldContent
    • FieldLabel
    • FieldTitle
    • FieldDescription
    • FieldSeparator
    • FieldError
  11. 11Accessibility
  12. 12Notes