Styled switch toggle with label and description, built on the @foldkit/ui Switch helper.

Preview
Style
Basic
With Label
With Description
Disabled
Sizes
src/demo/views/switch.ts207 lines
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { switch_ } from '../../generated/registry/ui/switch'
import {
  field,
  fieldContent,
  fieldDescription,
  fieldLabel,
  fieldTitle,
} from '../../generated/registry/ui/fieldset'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Message = defineMessageUnion({
  ToggledSwitch: { isChecked: S.Boolean },
})

export const switchView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          field<AppMessage>(
            { orientation: 'horizontal' },
            [
              switch_<AppMessage>(
                {
                  id: 'switch-basic',
                  label: 'Airplane Mode',
                  isChecked: model.isSwitchChecked,
                  onToggle: (isChecked) => Message.ToggledSwitch({ isChecked }),
                },
                h,
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Label']),
          h.div(
            [h.Class('flex items-center gap-2')],
            [
              switch_<AppMessage>(
                {
                  id: 'switch-bluetooth',
                  label: 'Bluetooth',
                  isChecked: true,
                  onToggle: () => Message.ToggledSwitch({ isChecked: true }),
                },
                h,
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Description']),
          fieldLabel<AppMessage>(
            { for: 'switch-focus-mode' },
            [
              field<AppMessage>(
                { orientation: 'horizontal' },
                [
                  fieldContent<AppMessage>(
                    {},
                    [
                      fieldTitle<AppMessage>({}, ['Share across devices'], h),
                      fieldDescription<AppMessage>(
                        {},
                        ['Focus is shared across devices, and turns off when you leave the app.'],
                        h,
                      ),
                    ],
                    h,
                  ),
                  switch_<AppMessage>(
                    {
                      id: 'switch-focus-mode',
                      label: '',
                      isChecked: false,
                      onToggle: () => Message.ToggledSwitch({ isChecked: false }),
                    },
                    h,
                  ),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Disabled']),
          h.div(
            [h.Class('flex flex-col gap-4')],
            [
              h.div(
                [h.Class('flex items-center gap-2')],
                [
                  switch_<AppMessage>(
                    {
                      id: 'switch-disabled-unchecked',
                      label: 'Disabled (Unchecked)',
                      isChecked: false,
                      isDisabled: true,
                      onToggle: () => Message.ToggledSwitch({ isChecked: false }),
                    },
                    h,
                  ),
                ],
              ),
              h.div(
                [h.Class('flex items-center gap-2')],
                [
                  switch_<AppMessage>(
                    {
                      id: 'switch-disabled-checked',
                      label: 'Disabled (Checked)',
                      isChecked: true,
                      isDisabled: true,
                      onToggle: () => Message.ToggledSwitch({ isChecked: true }),
                    },
                    h,
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Sizes']),
          h.div(
            [h.Class('flex flex-col gap-4')],
            [
              h.div(
                [h.Class('flex items-center gap-2')],
                [
                  switch_<AppMessage>(
                    {
                      id: 'switch-size-sm',
                      label: 'Small',
                      isChecked: false,
                      size: 'sm',
                      onToggle: () => Message.ToggledSwitch({ isChecked: false }),
                    },
                    h,
                  ),
                ],
              ),
              h.div(
                [h.Class('flex items-center gap-2')],
                [
                  switch_<AppMessage>(
                    {
                      id: 'switch-size-default',
                      label: 'Default',
                      isChecked: false,
                      size: 'default',
                      onToggle: () => Message.ToggledSwitch({ isChecked: false }),
                    },
                    h,
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
    ],
  )

const fields = { isSwitchChecked: S.Boolean }

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

export const slice = defineSlice({
  fields,
  init: { isSwitchChecked: false },
  messages: [Message.ToggledSwitch],
  handlers: (model: State) => ({
    ToggledSwitch: ({ isChecked }: typeof Message.ToggledSwitch.Type): UpdateReturn => ({
      model: evo(model, { isSwitchChecked: () => isChecked }),
    }),
  }),
  samples: [Message.ToggledSwitch({ isChecked: true })],
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/switch

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/switch.ts116 lines
import { Switch as FoldkitSwitch } from '@foldkit/ui'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { cn } from '@/lib/utils'

/**
 * foldkit deltas (inlined at style resolution): foldkit emits aria-disabled/
 * data-disabled instead of native disabled, and only data-checked (never
 * data-unchecked) on the ROOT button — this view hand-emits data-unchecked
 * there when off. Upstream Base UI also puts data-checked/data-unchecked on
 * the THUMB itself (the travel/track variants like
 * group-data-[size=default]/switch:data-checked:* key on the thumb's own
 * attribute), so the thumb span mirrors the state attribute too.
 *
 */

export const switchSizeKeys = ['default', 'sm'] as const
export type SwitchSize = (typeof switchSizeKeys)[number]

/** Upstream switch component string; sizes come from the cn-switch token via
 *  the data-size attribute. */
export const switchClass =
  'data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-3 aria-invalid:ring-3 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:border-transparent data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50'

/** Upstream thumb string; geometry/travel come from the token keyed on
 *  group data-size + data-checked/data-unchecked. */
export const switchThumbClass =
  'bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform'

export const switchLabelClass =
  'text-sm font-medium leading-none group-data-[disabled]/field:cursor-not-allowed group-data-[disabled]/field:opacity-70'

export const switchDescriptionClass = 'text-sm text-muted-foreground'

export const switchWrapperClass = 'group/field flex items-center gap-3'

export const switchTextWrapperClass = 'flex flex-col gap-1'

export type SwitchConfig<M> = Readonly<{
  id: string
  isChecked: boolean
  onToggle: (isChecked: boolean) => M
  label: string
  description?: string
  isDisabled?: boolean
  isReadOnly?: boolean
  name?: string
  value?: string
  size?: SwitchSize
  className?: string
  thumbClass?: string
  labelClass?: string
  descriptionClass?: string
  wrapperClass?: string
}>

/** Styled switch with label and optional description, built on the
 *  @foldkit/ui Switch helper. */
export const switch_ = <M>(config: SwitchConfig<M>, h: HtmlBuilder<M>): Html =>
  FoldkitSwitch.view<M>(
    {
      id: config.id,
      isChecked: config.isChecked,
      onToggle: config.onToggle,
      isDisabled: config.isDisabled,
      isReadOnly: config.isReadOnly,
      name: config.name,
      value: config.value,
      toView: (attributes) =>
        h.div(
          [
            h.Class(cn(switchWrapperClass, config.wrapperClass)),
            ...(config.isDisabled ? [h.DataAttribute('disabled', '')] : []),
          ],
          [
            h.button(
              [
                ...attributes.button,
                h.DataAttribute('slot', 'switch'),
                h.DataAttribute('size', config.size ?? 'default'),
                ...(config.isChecked ? [] : [h.DataAttribute('unchecked', '')]),
                h.Class(cn(switchClass, config.className)),
              ],
              [
                h.span([
                  h.DataAttribute('slot', 'switch-thumb'),
                  h.DataAttribute(config.isChecked ? 'checked' : 'unchecked', ''),
                  h.Class(cn(switchThumbClass, config.thumbClass)),
                ]),
              ],
            ),
            ...(attributes.hiddenInput.length > 0 ? [h.input([...attributes.hiddenInput])] : []),
            h.div(
              [h.Class(switchTextWrapperClass)],
              [
                h.label(
                  [...attributes.label, h.Class(cn(switchLabelClass, config.labelClass))],
                  [config.label],
                ),
                config.description === undefined
                  ? h.empty
                  : h.p(
                      [
                        ...attributes.description,
                        h.Class(cn(switchDescriptionClass, config.descriptionClass)),
                      ],
                      [config.description],
                    ),
              ],
            ),
          ],
        ),
    },
    h,
  )