Styled fieldset with legend and description, built on the @foldkit/ui Fieldset helper.

Preview
Style
Basic
Contact details

Used for shipping and billing.

With Multiple Fields
Profile

Update your profile information.

Disabled
Disabled Fieldset

All fields inside are disabled.

src/demo/views/fieldset.ts196 lines
import { Match as M, Option } from 'effect'
import { Schema as S } from 'effect'
import { Listbox as FoldkitListbox } from '@foldkit/ui'
import { Update } from 'foldkit'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { fieldset } from '../../generated/registry/ui/fieldset'
import { input } from '../../generated/registry/ui/input'
import * as select from '../../generated/registry/ui/select'
import { LanguageSelect } from '../bundles'

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

const Message = defineMessageUnion({
  UpdatedInputValue: { value: S.String },
  GotSelectMessage: { message: select.Message },
})

const LANGUAGE_OPTIONS = [
  ['en', 'English'],
  ['id', 'Bahasa Indonesia'],
  ['ja', '日本語'],
] as const

export const fieldsetView = (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']),
          fieldset<AppMessage>(
            {
              id: 'fieldset-contact',
              legend: 'Contact details',
              description: 'Used for shipping and billing.',
              children: [
                input<AppMessage>(
                  {
                    id: 'fieldset-name',
                    label: 'Name',
                    value: model.inputValue,
                    onInput: (value) => Message.UpdatedInputValue({ value }),
                    placeholder: 'Ada Lovelace',
                  },
                  h,
                ),
                h.div(
                  [h.Class(select.selectWrapperClass)],
                  [
                    select.selectLabel('Country', h),
                    h.submodel({
                      slotId: model.select.id,
                      model: model.select,
                      view: LanguageSelect.view,
                      viewInputs: select.styledViewInputs<
                        AppMessage,
                        { value: string; label: string },
                        string
                      >(
                        {
                          options: LANGUAGE_OPTIONS.map(([value, label]) => ({ value, label })),
                          maybeSelectedValue: model.maybeSelectValue,
                          itemToValue: (item) => item.value,
                          itemToLabel: (item) => item.label,
                          label: 'Country',
                        },
                        h,
                      ),
                      toParentMessage: (message) => Message.GotSelectMessage({ message }),
                    }),
                  ],
                ),
              ],
            },
            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 Multiple Fields'],
          ),
          fieldset<AppMessage>(
            {
              id: 'fieldset-profile',
              legend: 'Profile',
              description: 'Update your profile information.',
              children: [
                input<AppMessage>(
                  {
                    id: 'fieldset-email',
                    label: 'Email',
                    value: '',
                    onInput: (value) => Message.UpdatedInputValue({ value }),
                    placeholder: 'name@example.com',
                  },
                  h,
                ),
                input<AppMessage>(
                  {
                    id: 'fieldset-username',
                    label: 'Username',
                    value: '',
                    onInput: (value) => Message.UpdatedInputValue({ value }),
                    placeholder: 'johndoe',
                  },
                  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']),
          fieldset<AppMessage>(
            {
              id: 'fieldset-disabled',
              legend: 'Disabled Fieldset',
              description: 'All fields inside are disabled.',
              isDisabled: true,
              children: [
                input<AppMessage>(
                  {
                    id: 'fieldset-disabled-name',
                    label: 'Name',
                    value: '',
                    placeholder: 'Disabled',
                    isDisabled: true,
                  },
                  h,
                ),
              ],
            },
            h,
          ),
        ],
      ),
    ],
  )

const foldSelectOutMessage = M.type<FoldkitListbox.OutMessage<string>>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Selected:
      ({ value }) =>
      (model) => ({ model: evo(model, { maybeSelectValue: () => Option.some(value) }) }),
  }),
)

const foldSelect = Update.foldChild({
  update: LanguageSelect.update,
  read: (model: State) => Option.some(model.select),
  write: (model, next) => evo(model, { select: () => next }),
  toParentMessage: (message) => Message.GotSelectMessage({ message }),
  foldOutMessage: foldSelectOutMessage,
})

const fields = {
  inputValue: S.String,
  select: select.Model,
  maybeSelectValue: S.Option(S.String),
}

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

export const slice = defineSlice({
  fields,
  init: {
    inputValue: '',
    select: select.init({ id: 'select-language' }),
    maybeSelectValue: Option.some('en'),
  },
  messages: [Message.GotSelectMessage, Message.UpdatedInputValue],
  handlers: (model: State) => ({
    UpdatedInputValue: ({ value }: typeof Message.UpdatedInputValue.Type): UpdateReturn => ({
      model: evo(model, { inputValue: () => value }),
    }),
    GotSelectMessage: (payload: typeof Message.GotSelectMessage.Type): UpdateReturn =>
      foldSelect(model, payload.message),
  }),
  samples: [Message.UpdatedInputValue({ value: 'Ada Lovelace' })],
})

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

Source

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

registry/default/ui/fieldset.ts313 lines
import { Fieldset as FoldkitFieldset } from '@foldkit/ui'
import type { Html, HtmlBuilder } from 'foldkit/html'

type Child = Html | string

import { cn } from '@/lib/utils'
import { separatorClass } from './separator'

export const fieldsetClass = 'gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col'

export const fieldsetLegendClass = 'mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base'

export const fieldGroupClass =
  'gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col'

/** Upstream cva base + per-orientation variant strings. */
export const fieldBaseClass = 'data-[invalid=true]:text-destructive gap-2 group/field flex w-full'

export const fieldOrientationClasses = {
  vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
  horizontal:
    'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
  responsive:
    'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
} as const

export type FieldOrientation = keyof typeof fieldOrientationClasses

export const fieldContentClass =
  'gap-0.5 group/field-content flex flex-1 flex-col leading-snug'

/** Upstream keys label disabling on a native peer-disabled sibling variant
 *  (no foldkit `.peer` sibling exists) and `group-data-[disabled=true]`
 *  (foldkit emits `data-disabled=""`, never "true"). Re-keyed onto the
 *  fieldset wrapper's live group + data-disabled signal. */
export const labelClass =
  'gap-2 text-sm leading-none font-medium group-data-[disabled]:opacity-50 flex items-center select-none group-data-[disabled]/field-set:pointer-events-none group-data-[disabled]/field-set:cursor-not-allowed group-data-[disabled]/field-set:opacity-50'

export const fieldLabelClass =
  'has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 leading-snug group-data-[disabled]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 *:data-[slot=field]:p-2.5 group/field-label peer/field-label flex w-fit has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col'

export const fieldTitleClass = 'gap-2 text-sm font-medium group-data-[disabled]/field:opacity-50 flex w-fit items-center'

export const fieldDescriptionClass =
  'text-muted-foreground text-left text-sm [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance last:mt-0 nth-last-2:-mt-1 [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary'

export const fieldSeparatorClass = '-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative'

export const fieldSeparatorLineClass = 'absolute inset-0 top-1/2'

export const fieldSeparatorContentClass =
  'text-muted-foreground px-2 relative mx-auto block w-fit bg-background'

export const fieldErrorClass = 'text-destructive text-sm font-normal'

type StyleConfig = Readonly<{ className?: string }>

/** Outermost fieldset wrapper. */
export const fieldSet = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.fieldset(
    [h.DataAttribute('slot', 'field-set'), h.Class(cn(fieldsetClass, config.className))],
    children,
  )

/** Legend naming the fieldset. `variant` toggles legend vs label sizing. */
export const fieldLegend = <M>(
  config: StyleConfig & Readonly<{ variant?: 'legend' | 'label' }>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.legend(
    [
      h.DataAttribute('slot', 'field-legend'),
      h.DataAttribute('variant', config.variant ?? 'legend'),
      h.Class(cn(fieldsetLegendClass, config.className)),
    ],
    children,
  )

/** Groups a set of fields; provides the container context the responsive
 *  field orientation reacts to. */
export const fieldGroup = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.DataAttribute('slot', 'field-group'), h.Class(cn(fieldGroupClass, config.className))],
    children,
  )

/** A single field row: label + content + description + error. */
export const field = <M>(
  config: StyleConfig & Readonly<{ orientation?: FieldOrientation; isInvalid?: boolean }>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html => {
  const orientation = config.orientation ?? 'vertical'
  return h.div(
    [
      h.Role('group'),
      h.DataAttribute('slot', 'field'),
      h.DataAttribute('orientation', orientation),
      ...(config.isInvalid === true ? [h.DataAttribute('invalid', 'true')] : []),
      h.Class(cn(fieldBaseClass, fieldOrientationClasses[orientation], config.className)),
    ],
    children,
  )
}

/** Content column of a field (the control plus its description/error). */
export const fieldContent = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.DataAttribute('slot', 'field-content'), h.Class(cn(fieldContentClass, config.className))],
    children,
  )

/** Standalone label (drop-in for `label.tsx`'s `Label`). */
export const label = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.label([h.DataAttribute('slot', 'label'), h.Class(cn(labelClass, config.className))], children)

/** Label for a field; optionally associates with its control via `for`. */
export const fieldLabel = <M>(
  config: StyleConfig & Readonly<{ for?: string }>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.label(
    [
      ...(config.for === undefined ? [] : [h.For(config.for)]),
      h.DataAttribute('slot', 'field-label'),
      h.Class(cn(labelClass, fieldLabelClass, config.className)),
    ],
    children,
  )

/** Non-interactive title for a field group. */
export const fieldTitle = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.DataAttribute('slot', 'field-label'), h.Class(cn(fieldTitleClass, config.className))],
    children,
  )

/** Field description / helper text. */
export const fieldDescription = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.p(
    [
      h.DataAttribute('slot', 'field-description'),
      h.Class(cn(fieldDescriptionClass, config.className)),
    ],
    children,
  )

/** Divider between fields, with optional centered content. */
export const fieldSeparator = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html => {
  const hasContent = children.length > 0
  return h.div(
    [
      h.DataAttribute('slot', 'field-separator'),
      h.DataAttribute('content', hasContent ? 'true' : 'false'),
      h.Class(cn(fieldSeparatorClass, config.className)),
    ],
    [
      h.div([
        h.Role('separator'),
        h.DataAttribute('horizontal', ''),
        h.Class(cn(separatorClass, fieldSeparatorLineClass)),
      ]),
      ...(hasContent
        ? [
            h.span(
              [
                h.DataAttribute('slot', 'field-separator-content'),
                h.Class(cn(fieldSeparatorContentClass)),
              ],
              children,
            ),
          ]
        : []),
    ],
  )
}

/** Field error, alerting for explicit children or a list of unique error
 *  messages. Renders nothing when there is no content. */
export const fieldError = <M>(
  config: StyleConfig &
    Readonly<{
      children?: ReadonlyArray<Child>
      errors?: ReadonlyArray<{ message?: string } | undefined>
    }>,
  h: HtmlBuilder<M>,
): Html => {
  const hasChildren = config.children !== undefined && config.children.length > 0
  const uniqueErrors =
    config.errors === undefined
      ? []
      : [...new Map(config.errors.map((error) => [error?.message, error])).values()].filter(
          (error) => error?.message !== undefined,
        )

  let content: ReadonlyArray<Child> | undefined
  if (hasChildren) {
    content = config.children
  } else if (uniqueErrors.length === 1) {
    content = [uniqueErrors[0]!.message!]
  } else if (uniqueErrors.length > 1) {
    content = [
      h.ul(
        [h.Class('ml-4 flex list-disc flex-col gap-1')],
        uniqueErrors.map((error) => h.li([], [error!.message!])),
      ),
    ]
  }

  if (content === undefined) {
    return h.empty
  }

  return h.div(
    [
      h.Role('alert'),
      h.DataAttribute('slot', 'field-error'),
      h.Class(cn(fieldErrorClass, config.className)),
    ],
    content,
  )
}

export type FieldsetConfig = Readonly<{
  id: string
  legend: string
  description?: string
  isDisabled?: boolean
  className?: string
  legendClass?: string
  descriptionClass?: string
  contentClass?: string
  children: ReadonlyArray<Child>
}>

/** Styled fieldset with legend, optional description and grouped fields,
 *  built on the @foldkit/ui Fieldset helper for native fieldset/legend
 *  semantics plus id / aria-describedby / disabled wiring. */
export const fieldset = <M>(config: FieldsetConfig, h: HtmlBuilder<M>): Html =>
  FoldkitFieldset.view<M>(
    {
      id: config.id,
      isDisabled: config.isDisabled,
      toView: (attributes) =>
        h.fieldset(
          [
            ...attributes.fieldset,
            h.DataAttribute('slot', 'field-set'),
            h.Class(cn(fieldsetClass, config.className)),
          ],
          [
            h.legend(
              [
                ...attributes.legend,
                h.DataAttribute('slot', 'field-legend'),
                h.DataAttribute('variant', 'legend'),
                h.Class(cn(fieldsetLegendClass, config.legendClass)),
              ],
              [config.legend],
            ),
            config.description === undefined
              ? h.empty
              : h.p(
                  [
                    ...attributes.description,
                    h.DataAttribute('slot', 'field-description'),
                    h.Class(cn(fieldDescriptionClass, config.descriptionClass)),
                  ],
                  [config.description],
                ),
            h.div(
              [
                h.DataAttribute('slot', 'field-group'),
                h.Class(cn(fieldGroupClass, config.contentClass)),
              ],
              config.children,
            ),
          ],
        ),
    },
    h,
  )