Connected run of buttons forming a single segmented control, built on the @foldkit/ui Button helper.

Preview
Style
src/demo/views/button-group.ts56 lines
import type { Html, HtmlBuilder } from 'foldkit/html'

import { button } from '../../generated/registry/ui/button'
import { buttonGroup } from '../../generated/registry/ui/button-group'
import { icon } from '../../generated/registry/lib/icons'
import { ArrowLeft, MoreHorizontal } from 'lucide'

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

export const buttonGroupView = (model: Model, h: HtmlBuilder<Message>): Html =>
  buttonGroup<Message>(
    {},
    [
      buttonGroup<Message>(
        { className: 'hidden sm:flex' },
        [
          button<Message>(
            { variant: 'outline', size: 'icon', attributes: [h.AriaLabel('Go Back')] },
            icon(h, ArrowLeft),
            h,
          ),
        ],
        h,
      ),
      buttonGroup<Message>(
        {},
        [
          button<Message>({ variant: 'outline' }, 'Archive', h),
          button<Message>({ variant: 'outline' }, 'Report', h),
        ],
        h,
      ),
      buttonGroup<Message>(
        {},
        [
          button<Message>({ variant: 'outline' }, 'Snooze', h),
          button<Message>(
            { variant: 'outline', size: 'icon', attributes: [h.AriaLabel('More Options')] },
            icon(h, MoreHorizontal),
            h,
          ),
        ],
        h,
      ),
    ],
    h,
  )

export const slice = defineSlice({
  fields: {},
  init: {},
  messages: [],
  handlers: () => ({}),
})

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

Source

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

registry/default/ui/button-group.ts106 lines
import type { Html, HtmlBuilder } from 'foldkit/html'

import { cn } from '@/lib/utils'
import { button, type ButtonConfig, type ButtonSize, type ButtonVariant } from './button'
import { separatorClass } from './separator'

type Child = Html | string

// ButtonGroup connects a run of controls into a single segmented control.
// Children keep their own outlines; the group's corner-cutting rules join
// them (upstream model).

export const buttonGroupClass =
  "has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1"

export const buttonGroupOrientationClasses = {
  horizontal:
    '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! *:data-slot:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
  vertical:
    '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! flex-col *:data-slot:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0',
} as const

export type ButtonGroupOrientation = keyof typeof buttonGroupOrientationClasses

/** Upstream ButtonGroupText string. */
export const buttonGroupTextClass =
  'bg-muted gap-2 rounded-lg border px-2.5 text-sm font-medium [&_svg:not([class*=\'size-\'])]:size-4 flex items-center [&_svg]:pointer-events-none'

/** Upstream ButtonGroupSeparator string. */
export const buttonGroupSeparatorClass =
  'bg-input relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto'

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

/** Segmented container — pass buttons/selects/inputs as children. */
export const buttonGroup = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html => {
  const orientation = config.orientation ?? 'horizontal'
  return h.div(
    [
      h.Role('group'),
      h.Class(cn(buttonGroupClass, buttonGroupOrientationClasses[orientation], config.className)),
      h.DataAttribute('slot', 'button-group'),
      h.DataAttribute('orientation', orientation),
    ],
    children,
  )
}

/** Non-interactive text label inside a group. */
export const buttonGroupText = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.DataAttribute('slot', 'button-group-text'),
      h.Class(cn(buttonGroupTextClass, config.className)),
    ],
    children,
  )

// Upstream renders the shared <Separator> part; foldcn composes the same
// attribute set and class merge (separator base wins conflicts first) by hand.

/** Divider between grouped controls — a vertical `Separator` by default,
 *  matching upstream `ButtonGroupSeparator`. Upstream emits only
 *  `data-orientation` + `data-slot`; the `data-horizontal` / `data-vertical`
 *  selectors in the token are matched via the Separator's own
 *  `data-horizontal` / `data-vertical` attributes (see separator.ts). */
export const buttonGroupSeparator = <M>(config: StyleConfig, h: HtmlBuilder<M>): Html => {
  const orientation = config.orientation ?? 'vertical'
  return h.div(
    [
      h.Role('separator'),
      h.AriaOrientation(orientation),
      h.DataAttribute('slot', 'button-group-separator'),
      h.DataAttribute('orientation', orientation),
      h.DataAttribute(orientation, ''),
      h.Class(cn(separatorClass, buttonGroupSeparatorClass, config.className)),
    ],
    [],
  )
}

/** A `button` styled to sit inside a `buttonGroup`. */
export const buttonGroupItem = <M>(
  config: ButtonConfig<M>,
  label: Html | string,
  h: HtmlBuilder<M>,
): Html =>
  button<M>(
    {
      ...config,
      size: config.size ?? ('default' satisfies ButtonSize),
      variant: config.variant ?? ('outline' satisfies ButtonVariant),
      className: cn(config.className),
    },
    label,
    h,
  )