Stateful two-state toggle button marked with aria-pressed and data-state.

Preview
Style
Basic
Outline
Sizes
With Button
Disabled
With Icon
Interactive
src/demo/views/toggle.ts240 lines
import { Update } from 'foldkit'
import { Match as M, Option } from 'effect'
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import * as toggle from '../../generated/registry/ui/toggle'
import { icon } from '../../generated/registry/lib/icons'
import { Bookmark, Bold, Italic, Underline } from 'lucide'
import { button } from '../../generated/registry/ui/button'

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

const Message = defineMessageUnion({
  GotToggleMessage: { message: toggle.Message },
})

const staticToggle = (
  h: HtmlBuilder<AppMessage>,
  label: Html | string,
  opts?: {
    variant?: toggle.ToggleVariant
    size?: toggle.ToggleSize
    pressed?: boolean
    disabled?: boolean
  },
): Html =>
  h.button(
    [
      h.Type('button'),
      h.DataAttribute('slot', 'toggle'),
      h.DataAttribute('state', opts?.pressed === true ? 'on' : 'off'),
      h.AriaPressed(opts?.pressed === true ? 'true' : 'false'),
      ...(opts?.disabled === true ? [h.Disabled(true)] : []),
      h.Class(
        [
          toggle.toggleBase,
          toggle.toggleVariants[opts?.variant ?? 'default'],
          toggle.toggleSizes[opts?.size ?? 'default'],
        ].join(' '),
      ),
    ],
    [label],
  )

export const toggleView = (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']),
          h.div(
            [h.Class('flex flex-wrap items-center gap-2')],
            [
              staticToggle(h, icon(h, Bold, 'size-4'), { pressed: true }),
              staticToggle(h, icon(h, Italic, 'size-4')),
              staticToggle(h, icon(h, Underline, 'size-4')),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Outline']),
          h.div(
            [h.Class('flex flex-wrap items-center gap-2')],
            [
              staticToggle(h, h.span([], [icon(h, Italic, 'size-4'), ' Italic']), {
                variant: 'outline',
              }),
              staticToggle(h, h.span([], [icon(h, Bold, 'size-4'), ' Bold']), {
                variant: 'outline',
              }),
            ],
          ),
        ],
      ),
      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-wrap items-center gap-2')],
            [
              staticToggle(h, 'Small', { variant: 'outline', size: 'sm' }),
              staticToggle(h, 'Default', { variant: 'outline', size: 'default' }),
              staticToggle(h, 'Large', { variant: 'outline', size: 'lg' }),
            ],
          ),
        ],
      ),
      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 Button']),
          h.div(
            [h.Class('flex flex-col gap-4')],
            [
              h.div(
                [h.Class('flex items-center gap-2')],
                [
                  button<AppMessage>({ size: 'sm', variant: 'outline' }, 'Button', h),
                  staticToggle(h, 'Toggle', { variant: 'outline', size: 'sm' }),
                ],
              ),
              h.div(
                [h.Class('flex items-center gap-2')],
                [
                  button<AppMessage>({ size: 'default', variant: 'outline' }, 'Button', h),
                  staticToggle(h, 'Toggle', { variant: 'outline', size: 'default' }),
                ],
              ),
              h.div(
                [h.Class('flex items-center gap-2')],
                [
                  button<AppMessage>({ size: 'lg', variant: 'outline' }, 'Button', h),
                  staticToggle(h, 'Toggle', { variant: 'outline', size: 'lg' }),
                ],
              ),
            ],
          ),
        ],
      ),
      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-wrap items-center gap-2')],
            [
              staticToggle(h, 'Disabled', { disabled: true }),
              staticToggle(h, 'Disabled', { variant: 'outline', disabled: true }),
            ],
          ),
        ],
      ),
      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 Icon']),
          h.div(
            [h.Class('flex flex-wrap items-center gap-2')],
            [
              staticToggle(
                h,
                icon(h, Bookmark, 'size-4 group-data-[state=on]/toggle:fill-accent-foreground'),
                { pressed: true },
              ),
              staticToggle(
                h,
                h.span(
                  [],
                  [
                    icon(h, Bookmark, 'size-4 group-data-[state=on]/toggle:fill-accent-foreground'),
                    ' Bookmark',
                  ],
                ),
                { variant: 'outline' },
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Interactive']),
          h.div(
            [h.Class('flex w-full max-w-sm flex-col gap-4')],
            [
              h.submodel({
                slotId: model.toggle.id,
                model: model.toggle,
                view: toggle.view,
                viewInputs: {
                  variant: 'outline',
                  size: 'sm',
                  ariaLabel: 'Toggle bookmark',
                  label: h.span(
                    [],
                    [
                      icon(
                        h,
                        Bookmark,
                        'size-4 shrink-0 group-aria-pressed/toggle:fill-foreground',
                      ),
                      ' Bookmark',
                    ],
                  ),
                },
                toParentMessage: (message) => Message.GotToggleMessage({ message }),
              }),
            ],
          ),
        ],
      ),
    ],
  )

const foldNoOp =
  <Out>(): ((out: Out) => Update.Step<State, unknown>) =>
  () =>
  (model) => ({ model })

const foldToggleOutMessage = M.type<toggle.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    ChangedPressed: foldNoOp(),
  }),
)

const foldToggle = Update.foldChild({
  update: toggle.update,
  read: (model: State) => Option.some(model.toggle),
  write: (model, next) => evo(model, { toggle: () => next }),
  toParentMessage: (message) => Message.GotToggleMessage({ message }),
  foldOutMessage: foldToggleOutMessage,
})

const fields = { toggle: toggle.Model }

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

export const slice = defineSlice({
  fields,
  init: { toggle: toggle.init({ id: 'toggle-demo' }) },
  messages: [Message.GotToggleMessage],
  handlers: (model: State) => ({
    GotToggleMessage: (payload: typeof Message.GotToggleMessage.Type): UpdateReturn =>
      foldToggle(model, payload.message),
  }),
  samples: [],
})

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/toggle

Source

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

registry/default/ui/toggle.ts135 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as Toggle from '@/components/ui/toggle'`
 */
import { Function, Schema as S } from 'effect'
import type { Html } from 'foldkit/html'
import * as Update from 'foldkit/update'
import { defineMessageUnion } from 'foldkit/message'
import type { Reflect } from 'foldkit/submodel'
import { defineView } from 'foldkit/submodel'
import { evo } from 'foldkit/struct'

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

// Toggle is a two-state button (pressed / not) marked with `aria-pressed` and
// `data-state`. It owns its pressed state as a Submodel: embed it with
// `h.submodel` and listen for `ChangedPressed` if your app needs to react.
// Conform an externally-driven pressed state (URL, storage) with `reflect`.

export const toggleVariantKeys = ['default', 'outline'] as const
export type ToggleVariant = (typeof toggleVariantKeys)[number]

export const toggleVariants: Record<ToggleVariant, string> = {
  default: 'bg-transparent',
  outline: 'border-input hover:bg-muted border bg-transparent',
}

export const toggleSizeKeys = ['default', 'sm', 'lg'] as const
export type ToggleSize = (typeof toggleSizeKeys)[number]

export const toggleSizes: Record<ToggleSize, string> = {
  default: 'h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
  sm: 'h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=\'size-\'])]:size-3.5',
  lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
}

/** Upstream cva base string. The disabled: variants are inert under foldkit
 *  (this view emits native disabled; twins kept for parity). */
export const toggleBase =
  'hover:text-foreground aria-pressed:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[state=on]:bg-muted gap-1 rounded-lg text-sm font-medium transition-all [&_svg:not([class*=\'size-\'])]:size-4 group/toggle inline-flex items-center justify-center whitespace-nowrap outline-none hover:bg-muted focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'

// MODEL

export const Model = S.Struct({
  id: S.String,
  isPressed: S.Boolean,
})
export type Model = typeof Model.Type

// MESSAGES

/** The user clicked the toggle. Flips the pressed state. */
export const Message = defineMessageUnion({
  Toggled: {},
})
export type Message = typeof Message.Type

/** Emitted when the pressed state changes. */
export const OutMessage = defineMessageUnion({
  ChangedPressed: { isPressed: S.Boolean },
})
export type OutMessage = typeof OutMessage.Type

// INIT / UPDATE

export type InitConfig = Readonly<{
  id: string
  isPressed?: boolean
}>

/** Creates an initial toggle model. */
export const init = (config: InitConfig): Model => ({
  id: config.id,
  isPressed: config.isPressed ?? false,
})

/** Conforms an externally-driven pressed state onto the model without
 *  emitting an OutMessage (the world is the source of truth). */
export const reflect: Reflect<Model, boolean> = Function.dual(
  2,
  (model: Model, isPressed: boolean): Model => evo(model, { isPressed: () => isPressed }),
)

type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage>

/** Processes a toggle message and returns the next model, commands, and an
 *  optional out-message for the parent. */
export const update = (model: Model, message: Message): UpdateReturn => {
  switch (message._tag) {
    case 'Toggled': {
      const isPressed = !model.isPressed
      return {
        model: evo(model, { isPressed: () => isPressed }),
        outMessage: OutMessage.ChangedPressed({ isPressed }),
      }
    }
  }
}

// VIEW

export type ViewInputs = Readonly<{
  label: Html | string
  variant?: ToggleVariant
  size?: ToggleSize
  /** Marks the toggle unavailable with a native disabled attribute. */
  isDisabled?: boolean
  ariaLabel?: string
  className?: string
}>

/** Renders the two-state toggle button. Embedded via `h.submodel`. */
export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h) =>
  h.button(
    [
      h.Type('button'),
      ...(viewInputs.ariaLabel === undefined ? [] : [h.AriaLabel(viewInputs.ariaLabel)]),
      ...(viewInputs.isDisabled === true ? [h.Disabled(true)] : []),
      h.OnClick(Message.Toggled()),
      h.DataAttribute('slot', 'toggle'),
      h.DataAttribute('state', model.isPressed ? 'on' : 'off'),
      h.AriaPressed(model.isPressed ? 'true' : 'false'),
      h.Class(
        cn(
          toggleBase,
          toggleVariants[viewInputs.variant ?? 'default'],
          toggleSizes[viewInputs.size ?? 'default'],
          viewInputs.className,
        ),
      ),
    ],
    [viewInputs.label],
  ),
)