Skip to content

Switch

An immediate on/off setting, with leading alignment for forms and trailing alignment for settings rows.

Formsstarterformtogglesettingspreference

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { Switch } from '@/components/ui/choice'
import { Panel } from '@/components/ui/card'
import { DemoColumn, DemoStage } from './_kit'

export default function SwitchDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Panel title="Security" description="Changes apply immediately." flush>
          <div className="divide-y divide-[var(--color-border-subtle)]">
            <div className="px-4">
              <Switch
                id="switch-2fa"
                name="two-factor"
                align="trailing"
                defaultChecked
                label="Require two-factor authentication"
                description="Every member must enrol before their next sign-in."
              />
            </div>
            <div className="px-4">
              <Switch
                id="switch-sso"
                name="sso"
                align="trailing"
                label="Enforce SSO"
                description="Password sign-in is disabled once enabled."
              />
            </div>
            <div className="px-4">
              <Switch
                id="switch-audit"
                name="audit"
                align="trailing"
                disabled
                defaultChecked
                label="Audit log export"
                description="Always on for Business and above."
              />
            </div>
          </div>
        </Panel>

        <Switch
          id="switch-inline"
          name="beta"
          label="Join the beta channel"
          description="Leading alignment suits inline settings inside a form."
        />
      </DemoColumn>
    </DemoStage>
  )
}

components/demos/switch.tsx

import { Switch } from '@/components/ui/choice'
import { Panel } from '@/components/ui/card'
import { DemoColumn, DemoStage } from './_kit'

export default function SwitchDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Panel title="Security" description="Changes apply immediately." flush>
          <div className="divide-y divide-[var(--color-border-subtle)]">
            <div className="px-4">
              <Switch
                id="switch-2fa"
                name="two-factor"
                align="trailing"
                defaultChecked
                label="Require two-factor authentication"
                description="Every member must enrol before their next sign-in."
              />
            </div>
            <div className="px-4">
              <Switch
                id="switch-sso"
                name="sso"
                align="trailing"
                label="Enforce SSO"
                description="Password sign-in is disabled once enabled."
              />
            </div>
            <div className="px-4">
              <Switch
                id="switch-audit"
                name="audit"
                align="trailing"
                disabled
                defaultChecked
                label="Audit log export"
                description="Always on for Business and above."
              />
            </div>
          </div>
        </Panel>

        <Switch
          id="switch-inline"
          name="beta"
          label="Join the beta channel"
          description="Leading alignment suits inline settings inside a form."
        />
      </DemoColumn>
    </DemoStage>
  )
}

components/ui/choice.tsx

import type { InputHTMLAttributes, ReactNode } from 'react'
import { Check, Minus } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Checkbox / Radio / Switch
 *
 * All three keep a real, focusable native input in the DOM and paint the
 * visible control with a sibling element. That keeps `aria-checked`, form
 * submission, `:checked`, `:disabled` and keyboard behaviour native, while
 * still allowing a token-driven appearance.
 *
 * The native input is positioned over the visual control rather than hidden
 * with `display:none`, so the tap target is the full 44px row on touch.
 */

const controlBox = cn(
  'pointer-events-none flex shrink-0 items-center justify-center border transition-colors duration-150 ease-standard',
  'border-line-strong bg-surface text-accent-ink',
  'peer-hover:border-accent',
  'peer-checked:border-accent peer-checked:bg-accent',
  'peer-disabled:opacity-50',
  'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
  'peer-aria-[invalid=true]:border-danger',
  // The tick/dot is a *descendant* of this box, not a sibling of the input, so
  // the peer variant has to reach through with a child selector.
  '[&>*]:opacity-0 peer-checked:[&>*]:opacity-100',
)

const nativeInput = cn(
  'peer absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-not-allowed',
)

export interface ChoiceProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
  label: ReactNode
  description?: ReactNode
  /** `card` turns the whole row into a bordered, selectable surface. */
  appearance?: 'inline' | 'card'
}

export function Checkbox({
  label,
  description,
  appearance = 'inline',
  className,
  id,
  indeterminate,
  ...props
}: ChoiceProps & { indeterminate?: boolean }) {
  return (
    <label
      className={cn(
        'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
        appearance === 'card' &&
          'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
        appearance === 'inline' && 'py-1.5',
        className,
      )}
      htmlFor={id}
    >
      <span className="relative flex size-4.5 shrink-0 items-center justify-center">
        <input
          type="checkbox"
          id={id}
          className={nativeInput}
          aria-checked={indeterminate ? 'mixed' : undefined}
          {...props}
        />
        <span className={cn(controlBox, 'size-4.5 rounded-sm')} aria-hidden="true">
          {indeterminate ? (
            <Minus className="size-3" strokeWidth={3} />
          ) : (
            <Check className="size-3" strokeWidth={3} />
          )}
        </span>
      </span>
      <span className="min-w-0 flex-1 select-none">
        <span className="block leading-snug font-medium text-ink">{label}</span>
        {description ? (
          <span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
        ) : null}
      </span>
    </label>
  )
}

export function Radio({
  label,
  description,
  appearance = 'inline',
  className,
  id,
  ...props
}: ChoiceProps) {
  return (
    <label
      className={cn(
        'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
        appearance === 'card' &&
          'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
        appearance === 'inline' && 'py-1.5',
        className,
      )}
      htmlFor={id}
    >
      <span className="relative flex size-4.5 shrink-0 items-center justify-center">
        <input type="radio" id={id} className={nativeInput} {...props} />
        <span className={cn(controlBox, 'size-4.5 rounded-full')} aria-hidden="true">
          <span className="size-1.5 rounded-full bg-current" />
        </span>
      </span>
      <span className="min-w-0 flex-1 select-none">
        <span className="block leading-snug font-medium text-ink">{label}</span>
        {description ? (
          <span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
        ) : null}
      </span>
    </label>
  )
}

export interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'type'> {
  label: ReactNode
  description?: ReactNode
  /** Places the switch on the trailing edge — the settings-row convention. */
  align?: 'leading' | 'trailing'
}

export function Switch({
  label,
  description,
  align = 'leading',
  className,
  id,
  ...props
}: SwitchProps) {
  const control = (
    <span className="relative inline-flex h-5 w-9 shrink-0 items-center">
      <input type="checkbox" role="switch" id={id} className={nativeInput} {...props} />
      <span
        className={cn(
          'pointer-events-none h-5 w-9 rounded-full border border-line-strong bg-surface-sunken transition-colors duration-150 ease-standard',
          'peer-checked:border-accent peer-checked:bg-accent',
          'peer-disabled:opacity-50',
          'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
        )}
        aria-hidden="true"
      />
      <span
        className={cn(
          'pointer-events-none absolute left-0.5 size-4 rounded-full bg-surface shadow-sm transition-transform duration-150 ease-standard',
          'border border-line peer-checked:translate-x-4 peer-checked:border-transparent',
        )}
        aria-hidden="true"
      />
    </span>
  )

  return (
    <label
      className={cn(
        'flex min-h-9 cursor-pointer items-start gap-3 py-1.5 text-sm has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
        align === 'trailing' && 'justify-between',
        className,
      )}
      htmlFor={id}
    >
      {align === 'leading' ? control : null}
      <span className="min-w-0 flex-1 select-none">
        <span className="block leading-snug font-medium text-ink">{label}</span>
        {description ? (
          <span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
        ) : null}
      </span>
      {align === 'trailing' ? control : null}
    </label>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

A switch means the change takes effect now. If the value only applies after pressing Save, it is a checkbox, not a switch — the affordance is a promise about timing.

  • Label the thing being controlled, not the state: "Require two-factor authentication", never "Enabled".
  • Trailing alignment reads better in a divided settings panel; leading alignment reads better in a form.
  • A switch has no indeterminate state.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Leading — inside a form
  • Trailing — settings row
  • Checked
  • Disabled

Accessibility

Role
A native checkbox with `role="switch"`, so the state is announced as on/off.
Label
The whole row is the label element, giving a large, forgiving target.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.