Composable tabbed interface — Tabs.list, Tabs.trigger, Tabs.content. Built on the @foldkit/ui Tabs submodel.

Preview
Style
Basic
Line
Disabled
With Icons
Icon Only
With Content
Overview
View your key metrics and recent project activity. Track progress across all your active projects.

You have 12 active projects and 3 pending tasks.

Vertical
Manage your account preferences and profile information.
With Input and Button
src/demo/views/tabs.ts297 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 { Tabs as FoldkitTabs } from '@foldkit/ui'

import * as tabs from '../../generated/registry/ui/tabs'
import { tabsListClass, tabsTriggerClass, tabsContentClass } from '../../generated/registry/ui/tabs'
import { Card } from '../../generated/registry/ui/card'
import { icon } from '../../generated/registry/lib/icons'
import { AppWindow, Code, Home, Search, Settings } from 'lucide'

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

const Message = defineMessageUnion({
  GotTabsMessage: { message: tabs.Message },
})

const TAB_DETAILS: Record<DemoTab, { title: string; description: string; content: string }> = {
  Overview: {
    title: 'Overview',
    description:
      'View your key metrics and recent project activity. Track progress across all your active projects.',
    content: 'You have 12 active projects and 3 pending tasks.',
  },
  Analytics: {
    title: 'Analytics',
    description:
      'Track performance and user engagement metrics. Monitor trends and identify growth opportunities.',
    content: 'Page views are up 25% compared to last month.',
  },
  Reports: {
    title: 'Reports',
    description:
      'Generate and download your detailed reports. Export data in multiple formats for analysis.',
    content: 'You have 5 reports ready and available to export.',
  },
  Settings: {
    title: 'Settings',
    description:
      'Manage your account preferences and options. Customize your experience to fit your needs.',
    content: 'Configure notifications, security, and themes.',
  },
}

const staticTabs = (
  h: HtmlBuilder<AppMessage>,
  labels: ReadonlyArray<string>,
  activeIndex: number,
  variant: 'default' | 'line' = 'default',
  disabledIndex?: number,
): Html =>
  h.div(
    [h.Class('flex flex-col gap-2')],
    [
      h.div(
        [
          h.Class(tabsListClass(variant)),
          h.DataAttribute('slot', 'tabs-list'),
          h.Attribute('data-variant', variant),
          h.DataAttribute('orientation', 'horizontal'),
          h.DataAttribute('horizontal', ''),
        ],
        labels.map((label, idx) =>
          h.button(
            [
              h.Class(tabsTriggerClass),
              h.DataAttribute('slot', 'tabs-trigger'),
              ...(idx === activeIndex
                ? [h.DataAttribute('data-selected', ''), h.Attribute('data-selected', '')]
                : []),
              ...(disabledIndex === idx ? [h.Disabled(true), h.AriaDisabled(true)] : []),
            ],
            [label],
          ),
        ),
      ),
    ],
  )

const staticTabsWithIcons = (h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class(tabsListClass('default')), h.DataAttribute('slot', 'tabs-list')],
    [
      h.button(
        [
          h.Class(tabsTriggerClass),
          h.DataAttribute('slot', 'tabs-trigger'),
          h.Attribute('data-selected', ''),
        ],
        [icon(h, AppWindow, 'size-4'), ' Preview'],
      ),
      h.button(
        [h.Class(tabsTriggerClass), h.DataAttribute('slot', 'tabs-trigger')],
        [icon(h, Code, 'size-4'), ' Code'],
      ),
    ],
  )

const staticIconOnly = (h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class(tabsListClass('default')), h.DataAttribute('slot', 'tabs-list')],
    [
      h.button(
        [h.Class(tabsTriggerClass), h.Attribute('data-selected', '')],
        [icon(h, Home, 'size-4')],
      ),
      h.button([h.Class(tabsTriggerClass)], [icon(h, Search, 'size-4')]),
      h.button([h.Class(tabsTriggerClass)], [icon(h, Settings, 'size-4')]),
    ],
  )

export const tabsView = (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']),
          staticTabs(h, ['Home', 'Settings'], 0),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Line']),
          staticTabs(h, ['Overview', 'Analytics', 'Reports'], 0, 'line'),
        ],
      ),
      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']),
          staticTabs(h, ['Home', 'Disabled'], 0, 'default', 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')], ['With Icons']),
          staticTabsWithIcons(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')], ['Icon Only']),
          staticIconOnly(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 Content']),
          h.submodel({
            slotId: model.tabs.id,
            model: model.tabs,
            view: DemoTabs.view,
            viewInputs: tabs.styledViewInputs<AppMessage, DemoTab>(
              {
                tabs: ['Overview', 'Analytics', 'Reports', 'Settings'],
                selectedValue: model.activeTab,
                ariaLabel: 'Demo tabs',
                panel: (tab, _render, h2) => {
                  const details = TAB_DETAILS[tab]
                  return Card<AppMessage>(
                    {},
                    [
                      Card.header<AppMessage>(
                        {},
                        [
                          Card.title<AppMessage>({}, [details.title], h2),
                          Card.description<AppMessage>({}, [details.description], h2),
                        ],
                        h2,
                      ),
                      Card.content<AppMessage>(
                        {},
                        [h2.p([h2.Class('text-sm text-muted-foreground')], [details.content])],
                        h2,
                      ),
                    ],
                    h2,
                  )
                },
              },
              h,
            ),
            toParentMessage: (message) => Message.GotTabsMessage({ 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')], ['Vertical']),
          h.div(
            [h.Class('flex gap-4')],
            [
              h.div(
                [
                  h.Class(`${tabsListClass('default')} flex-col h-fit`),
                  h.DataAttribute('slot', 'tabs-list'),
                ],
                [
                  h.button(
                    [h.Class(tabsTriggerClass), h.Attribute('data-selected', '')],
                    ['Account'],
                  ),
                  h.button([h.Class(tabsTriggerClass)], ['Password']),
                  h.button([h.Class(tabsTriggerClass)], ['Notifications']),
                ],
              ),
              h.div(
                [h.Class(`${tabsContentClass} border rounded-lg p-4 flex-1`)],
                ['Manage your account preferences and profile information.'],
              ),
            ],
          ),
        ],
      ),
      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 Input and Button'],
          ),
          h.div(
            [h.Class('flex items-center gap-2')],
            [
              staticTabs(h, ['Overview', 'Analytics'], 0),
              h.div(
                [h.Class('ml-auto flex items-center gap-2')],
                [
                  h.input([
                    h.Class(
                      'flex h-8 w-32 rounded-md border border-input bg-transparent px-2 text-sm',
                    ),
                    h.Placeholder('Search...'),
                  ]),
                  h.button(
                    [
                      h.Class(
                        'inline-flex h-8 items-center justify-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground',
                      ),
                    ],
                    ['Action'],
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
    ],
  )

const foldTabsOutMessage = M.type<FoldkitTabs.OutMessage<DemoTab>>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Selected:
      ({ value }) =>
      (model) => ({ model: evo(model, { activeTab: () => value }) }),
  }),
)

const foldTabs = Update.foldChild({
  update: DemoTabs.update,
  read: (model: State) => Option.some(model.tabs),
  write: (model, next) => evo(model, { tabs: () => next }),
  toParentMessage: (message) => Message.GotTabsMessage({ message }),
  foldOutMessage: foldTabsOutMessage,
})

const fields = { tabs: tabs.Model, activeTab: DemoTab }

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

export const slice = defineSlice({
  fields,
  init: { tabs: tabs.init({ id: 'tabs-demo' }), activeTab: 'Overview' },
  messages: [Message.GotTabsMessage],
  handlers: (model: State) => ({
    GotTabsMessage: (payload: typeof Message.GotTabsMessage.Type): UpdateReturn =>
      foldTabs(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/tabs

Source

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

registry/default/ui/tabs.ts177 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as Tabs from '@/components/ui/tabs'`
 */
import { Tabs as FoldkitTabs } from '@foldkit/ui'
import type { Html, HtmlBuilder } from 'foldkit/html'

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

// Re-export the @foldkit/ui Tabs surface. Create a bundle once per tab value
// type:
//
//   export const DemoTabs = Tabs.create<"Foldkit" | "React">()

export const create = FoldkitTabs.create
export const init = FoldkitTabs.init
export const Model = FoldkitTabs.Model
export type Model = typeof Model.Type
export const Message = FoldkitTabs.Message
export type Message = typeof Message.Type
export const OutMessage = FoldkitTabs.OutMessage
export type OutMessage = typeof OutMessage.Type

export type Bundle<Value extends string = string> = FoldkitTabs.Bundle<Value>
export type InitConfig = FoldkitTabs.InitConfig
export type ViewInputs<Value extends string = string> = FoldkitTabs.ViewInputs<Value>
export type RenderInfo<Value extends string = string> = FoldkitTabs.RenderInfo<Value>

// foldkit delta: foldkit emits data-selected (upstream Base UI emits
// data-active) — the copied trigger string keeps the semantics with the
// data-active prefix per docs/deriving-from-base.md. The group orientation
// hooks use data-horizontal/data-vertical attrs emitted by the styled view.

export type TabsListVariant = 'default' | 'line'

const tabsListBaseClass =
  'rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col'

const tabsListVariantClasses: Record<TabsListVariant, string> = {
  default: 'bg-muted',
  line: 'gap-1 bg-transparent',
}

export const tabsListClass = (variant: TabsListVariant = 'default') =>
  cn(tabsListBaseClass, tabsListVariantClasses[variant])

/** Upstream TabsTrigger string with data-active → data-selected prefix swaps
 *  (foldkit attr name). */
export const tabsTriggerClass = cn(
  'gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*=\'size-\'])]:size-4 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
  'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-selected:bg-transparent dark:group-data-[variant=line]/tabs-list:data-selected:border-transparent dark:group-data-[variant=line]/tabs-list:data-selected:bg-transparent',
  'data-selected:bg-background data-selected:text-foreground dark:data-selected:border-input dark:data-selected:bg-input/30 dark:data-selected:text-foreground',
  'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-selected:after:opacity-100',
)

export const tabsContentClass = 'text-sm flex-1 outline-none'

// Use inside `styledViewInputs` panel callbacks:
//
//   panel: (tab, render, h) =>
//     Tabs.content({}, [h.p([], [`${tab} content`])], h)

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

/** Tab list wrapper. */
export const list = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Html | string>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.DataAttribute('slot', 'tabs-list'), h.Class(cn(tabsListClass(), config.className))],
    children,
  )

/** Individual tab trigger button. */
export const trigger = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Html | string>,
  h: HtmlBuilder<M>,
): Html =>
  h.button(
    [h.DataAttribute('slot', 'tabs-trigger'), h.Class(cn(tabsTriggerClass, config.className))],
    children,
  )

/** Tab content panel wrapper. */
export const content = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Html | string>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.DataAttribute('slot', 'tabs-content'), h.Class(cn(tabsContentClass, config.className))],
    children,
  )

export type StyledViewInputs<M, Value extends string = string> = Readonly<{
  tabs: ReadonlyArray<Value>
  selectedValue: Value
  ariaLabel: string
  /** Renders each tab panel. Receives the tab value and the render-time
   *  attributes (tablist, tabs, activeIndex) so content can react to the
   *  active tab. */
  panel: (tab: Value, render: RenderInfo<Value>, h: HtmlBuilder<M>) => Html
  isTabDisabled?: (value: Value, index: number) => boolean
  orientation?: 'Horizontal' | 'Vertical'
  variant?: TabsListVariant
  listClass?: string
  triggerClass?: string
  contentClass?: string
}>

/** Build styled `Tabs.ViewInputs`. Pass your view's `h` so panel content can
 *  dispatch your app's own messages. */
export const styledViewInputs = <M, Value extends string = string>(
  viewInputs: StyledViewInputs<M, Value>,
  h: HtmlBuilder<M>,
): ViewInputs<Value> => {
  const isVertical = viewInputs.orientation === 'Vertical'
  const variant = viewInputs.variant ?? 'default'
  return {
    tabs: viewInputs.tabs,
    selectedValue: viewInputs.selectedValue,
    ariaLabel: viewInputs.ariaLabel,
    isTabDisabled: viewInputs.isTabDisabled,
    orientation: viewInputs.orientation,
    toView: ({ tablist, tabs, activeIndex }) =>
      h.div(
        [
          h.DataAttribute('slot', 'tabs'),
          h.DataAttribute('orientation', isVertical ? 'vertical' : 'horizontal'),
          ...(isVertical ? [h.DataAttribute('vertical', '')] : [h.DataAttribute('horizontal', '')]),
          h.Class(
            cn(
              'gap-2 group/tabs flex',
              isVertical ? 'w-full gap-2' : 'flex-col',
              viewInputs.variant === 'line' ? '' : '',
            ),
          ),
        ],
        [
          h.div(
            [
              ...tablist,
              h.DataAttribute('slot', 'tabs-list'),
              h.Attribute('data-variant', variant),
              h.Class(cn(tabsListClass(variant), viewInputs.listClass)),
            ],
            tabs.map((tab) =>
              h.button(
                [
                  ...tab.tab,
                  h.DataAttribute('slot', 'tabs-trigger'),
                  h.Class(cn(tabsTriggerClass, viewInputs.triggerClass)),
                ],
                [h.span([], [tab.value])],
              ),
            ),
          ),
          ...tabs
            .filter((tab) => tab.index === activeIndex)
            .map((tab) =>
              h.div(
                [
                  ...tab.panel,
                  h.DataAttribute('slot', 'tabs-content'),
                  h.Class(cn(tabsContentClass, viewInputs.contentClass)),
                ],
                [viewInputs.panel(tab.value, { tablist, tabs, activeIndex }, h)],
              ),
            ),
        ],
      ),
  }
}