Stateful group of toggles with shared single or multiple selection.

Preview
Style
Basic
Outline
Sizes
With Icons
Filter
Sort
Vertical
Font Weight Selector

Use font-normal to set the font weight.

src/demo/views/toggle-group.ts279 lines
import { Update } from 'foldkit'
import { Bold, Italic, Underline, Star, Heart, Bookmark } from 'lucide'
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 toggleGroup from '../../generated/registry/ui/toggle-group'
import * as toggle from '../../generated/registry/ui/toggle'
import { icon } from '../../generated/registry/lib/icons'
import { field, fieldDescription, fieldLabel } from '../../generated/registry/ui/fieldset'

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

const Message = defineMessageUnion({
  GotToggleGroupMessage: { message: toggleGroup.Message },
})

const staticGroup = (
  h: HtmlBuilder<AppMessage>,
  items: ReadonlyArray<{ value: string; label: string; icon?: typeof Bold }>,
  opts?: {
    variant?: toggle.ToggleVariant
    size?: toggle.ToggleSize
    spacing?: number
    orientation?: 'horizontal' | 'vertical'
  },
): Html => {
  const variant = opts?.variant ?? 'default'
  const size = opts?.size ?? 'default'
  const spacing = opts?.spacing ?? 2
  const orientation = opts?.orientation ?? 'horizontal'
  return h.div(
    [
      h.Role('group'),
      h.Class(toggleGroup.toggleGroupClass),
      h.DataAttribute('slot', 'toggle-group'),
      h.DataAttribute('orientation', orientation),
      h.DataAttribute('spacing', String(spacing)),
      h.DataAttribute('variant', variant),
      h.DataAttribute('size', size),
      ...(orientation === 'vertical'
        ? [h.DataAttribute('vertical', '')]
        : [h.DataAttribute('horizontal', '')]),
      h.Style({ '--gap': String(spacing) }),
    ],
    items.map((item) =>
      h.button(
        [
          h.Type('button'),
          h.DataAttribute('slot', 'toggle-group-item'),
          h.DataAttribute('variant', variant),
          h.DataAttribute('size', size),
          h.DataAttribute('spacing', String(spacing)),
          h.DataAttribute('state', 'off'),
          h.AriaPressed('false'),
          h.Class(
            [
              toggle.toggleBase,
              toggle.toggleVariants[variant],
              toggle.toggleSizes[size],
              toggleGroup.toggleGroupItemClass,
            ].join(' '),
          ),
        ],
        [
          item.icon === undefined
            ? item.label
            : h.span([], [icon(h, item.icon, 'size-4'), ` ${item.label}`.trimStart()]),
        ],
      ),
    ),
  )
}

export const toggleGroupView = (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.submodel({
            slotId: model.toggleGroup.id,
            model: model.toggleGroup,
            view: toggleGroup.view,
            viewInputs: {
              variant: 'outline',
              items: [
                { value: 'bold', label: 'Bold', icon: Bold },
                { value: 'italic', label: 'Italic', icon: Italic },
                { value: 'strikethrough', label: 'Strikethrough', icon: Underline },
              ],
            },
            toParentMessage: (message) => Message.GotToggleGroupMessage({ message }),
          }),
        ],
      ),
      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']),
          staticGroup(
            h,
            [
              { value: 'all', label: 'All' },
              { value: 'missed', label: 'Missed' },
            ],
            { 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-col gap-4')],
            [
              staticGroup(
                h,
                [
                  { value: 'top', label: 'Top' },
                  { value: 'bottom', label: 'Bottom' },
                  { value: 'left', label: 'Left' },
                  { value: 'right', label: 'Right' },
                ],
                { variant: 'outline', size: 'sm' },
              ),
              staticGroup(
                h,
                [
                  { value: 'top', label: 'Top' },
                  { value: 'bottom', label: 'Bottom' },
                  { value: 'left', label: 'Left' },
                  { value: 'right', label: 'Right' },
                ],
                { variant: 'outline', size: 'default' },
              ),
            ],
          ),
        ],
      ),
      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 Icons']),
          staticGroup(
            h,
            [
              { value: 'star', label: 'Star', icon: Star },
              { value: 'heart', label: 'Heart', icon: Heart },
              { value: 'bookmark', label: 'Bookmark', icon: Bookmark },
            ],
            { variant: 'outline', spacing: 2, size: 'sm' },
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Filter']),
          staticGroup(
            h,
            [
              { value: 'all', label: 'All' },
              { value: 'active', label: 'Active' },
              { value: 'completed', label: 'Completed' },
              { value: 'archived', label: 'Archived' },
            ],
            { variant: 'outline', size: 'sm' },
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Sort']),
          staticGroup(
            h,
            [
              { value: 'newest', label: 'Newest' },
              { value: 'oldest', label: 'Oldest' },
              { value: 'popular', label: 'Popular' },
            ],
            { variant: 'outline', size: 'sm' },
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Vertical']),
          staticGroup(
            h,
            [
              { value: 'bold', label: '', icon: Bold },
              { value: 'italic', label: '', icon: Italic },
              { value: 'underline', label: '', icon: Underline },
            ],
            { orientation: 'vertical', spacing: 1 },
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div(
            [h.Class('px-1 text-xs font-medium text-muted-foreground')],
            ['Font Weight Selector'],
          ),
          field<AppMessage>(
            {},
            [
              fieldLabel<AppMessage>({}, ['Font Weight'], h),
              staticGroup(
                h,
                [
                  { value: 'light', label: 'Light' },
                  { value: 'normal', label: 'Normal' },
                  { value: 'medium', label: 'Medium' },
                  { value: 'bold', label: 'Bold' },
                ],
                { variant: 'outline', spacing: 2, size: 'default' },
              ),
              fieldDescription<AppMessage>({}, ['Use font-normal to set the font weight.'], h),
            ],
            h,
          ),
        ],
      ),
    ],
  )

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

const foldToggleGroupOutMessage = M.type<toggleGroup.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    ChangedValue: foldNoOp(),
  }),
)

const foldToggleGroup = Update.foldChild({
  update: toggleGroup.update,
  read: (model: State) => Option.some(model.toggleGroup),
  write: (model, next) => evo(model, { toggleGroup: () => next }),
  toParentMessage: (message) => Message.GotToggleGroupMessage({ message }),
  foldOutMessage: foldToggleGroupOutMessage,
})

const fields = { toggleGroup: toggleGroup.Model }

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

export const slice = defineSlice({
  fields,
  init: {
    toggleGroup: toggleGroup.init({ id: 'toggle-group-demo', type: 'multiple', value: ['bold'] }),
  },
  messages: [Message.GotToggleGroupMessage],
  handlers: (model: State) => ({
    GotToggleGroupMessage: (payload: typeof Message.GotToggleGroupMessage.Type): UpdateReturn =>
      foldToggleGroup(model, payload.message),
  }),
  samples: [
    Message.GotToggleGroupMessage({
      message: toggleGroup.Message.ToggledItem({ value: 'italic' }),
    }),
  ],
})

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-group

Source

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

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

import { cn } from '@/lib/utils'
import { icon } from '@/lib/icons'
import {
  toggleBase,
  toggleSizes,
  toggleVariants,
  type ToggleSize,
  type ToggleVariant,
} from './toggle'

type IconNode = Parameters<typeof icon>[1]

// ToggleGroup is a set of toggles that share a single (or multiple) selection.
// It owns the selection as a Submodel: embed it with `h.submodel` and listen
// for `ChangedValue` to lift selection changes into your own model. Conform an
// externally-driven selection with `reflect`.
//
// Upstream renders a loose flex row joined only when spacing is 0; foldcn
// keeps the same model via the `spacing` config (default 2, matching
// upstream). Item defaults follow upstream: variant "default", size
// "default".

export const toggleGroupClass =
  'rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] data-vertical:flex-col data-vertical:items-stretch'

/** Upstream item string (joined-strip rules apply only at spacing 0). */
export const toggleGroupItemClass =
  'group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg shrink-0 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t'

export const Type = S.Literals(['single', 'multiple'])
export type ToggleGroupType = typeof Type.Type

export type ToggleGroupOrientation = 'horizontal' | 'vertical'

export type ToggleGroupItem = Readonly<{
  value: string
  label: string
  icon?: IconNode
  ariaLabel?: string
}>

// MODEL

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

// MESSAGES

/** The user clicked one of the toggles. Flips that item's membership in the
 *  selection — replacing it entirely when `type` is `single`. */
export const Message = defineMessageUnion({
  ToggledItem: { value: S.String },
})
export type Message = typeof Message.Type

/** Emitted when the selection changes. */
export const OutMessage = defineMessageUnion({
  ChangedValue: { value: S.Array(S.String) },
})
export type OutMessage = typeof OutMessage.Type

const nextValue = (
  current: ReadonlyArray<string>,
  value: string,
  type: ToggleGroupType,
): ReadonlyArray<string> => {
  const isSelected = current.includes(value)
  if (type === 'single') return isSelected ? [] : [value]
  return isSelected ? current.filter((v) => v !== value) : [...current, value]
}

// INIT / UPDATE

export type InitConfig = Readonly<{
  id: string
  type?: ToggleGroupType
  value?: ReadonlyArray<string>
}>

/** Creates an initial toggle group model. */
export const init = (config: InitConfig): Model => ({
  id: config.id,
  type: config.type ?? 'single',
  value: config.value === undefined ? [] : [...config.value],
})

/** Conforms an externally-driven selection onto the model without emitting an
 *  OutMessage (the world is the source of truth). */
export const reflect: Reflect<Model, ReadonlyArray<string>> = Function.dual(
  2,
  (model: Model, value: ReadonlyArray<string>): Model => evo(model, { value: () => [...value] }),
)

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

/** Processes a toggle group 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 'ToggledItem': {
      const value = nextValue(model.value, message.value, model.type)
      return {
        model: evo(model, { value: () => [...value] }),
        outMessage: OutMessage.ChangedValue({ value }),
      }
    }
  }
}

// VIEW

export type ViewInputs = Readonly<{
  items: ReadonlyArray<ToggleGroupItem>
  variant?: ToggleVariant
  size?: ToggleSize
  isDisabled?: boolean
  /** Gap between items in spacing units. `0` joins items into a strip.
   *  Defaults to 2 like upstream. */
  spacing?: number
  orientation?: ToggleGroupOrientation
  ariaLabel?: string
  className?: string
}>

/** Renders the group of toggles sharing the submodel's selection. Embedded
 *  via `h.submodel`. */
export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h) => {
  const spacing = viewInputs.spacing ?? 2
  const orientation = viewInputs.orientation ?? 'horizontal'
  return h.div(
    [
      ...(viewInputs.ariaLabel === undefined ? [] : [h.AriaLabel(viewInputs.ariaLabel)]),
      h.Role('group'),
      h.Class(cn(toggleGroupClass, viewInputs.className)),
      h.DataAttribute('slot', 'toggle-group'),
      h.DataAttribute('orientation', orientation),
      h.DataAttribute('spacing', String(spacing)),
      h.DataAttribute('variant', viewInputs.variant ?? 'default'),
      h.DataAttribute('size', viewInputs.size ?? 'default'),
      ...(orientation === 'vertical'
        ? [h.DataAttribute('vertical', '')]
        : [h.DataAttribute('horizontal', '')]),
      h.Style({ '--gap': String(spacing) }),
    ],
    viewInputs.items.map((item) =>
      h.button(
        [
          h.Type('button'),
          ...(item.ariaLabel === undefined ? [] : [h.AriaLabel(item.ariaLabel)]),
          ...(viewInputs.isDisabled === true ? [h.Disabled(true)] : []),
          h.OnClick(Message.ToggledItem({ value: item.value })),
          h.DataAttribute('slot', 'toggle-group-item'),
          h.DataAttribute('variant', viewInputs.variant ?? 'default'),
          h.DataAttribute('size', viewInputs.size ?? 'default'),
          h.DataAttribute('spacing', String(spacing)),
          h.DataAttribute('state', model.value.includes(item.value) ? 'on' : 'off'),
          h.AriaPressed(model.value.includes(item.value) ? 'true' : 'false'),
          h.Class(
            cn(
              toggleBase,
              toggleVariants[viewInputs.variant ?? 'default'],
              toggleSizes[viewInputs.size ?? 'default'],
              toggleGroupItemClass,
            ),
          ),
        ],
        [item.icon === undefined ? item.label : h.span([], [icon(h, item.icon), item.label])],
      ),
    ),
  )
})