Skip to main content
On this pageOverview

Tabs

Overview

Tab panel navigation with roving tabindex keyboard support, horizontal and vertical orientation, and automatic or manual activation modes. Tabs renders a tab list with buttons and corresponding panels. Only the active panel is visible.

See it in an app

Check out how Tabs is wired up in a real Foldkit app.

Examples

Horizontal

Declare the tabs component once at module scope with Ui.Tabs.create<Value>() to lift the tab type through view, update, and selectTab without casting. Pass the typed tabs array and a toView callback that receives one TabInfo<Value> per tab (with attribute bundles for the tab button and its panel).

Model-View-Update with Effect. A single immutable model holds all state, messages describe what happened, and a pure update function produces the next state. Side effects are explicit Commands, never hidden in the view layer.

Composable “The Elm Architecture” modules, Schema-typed state, and controlled side effects via Effect.

// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt. Fit them into your own Model, init, Message,
// update, and view definitions.
import { Match as M, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

// Add a field to your Model for the Tabs Submodel:
const Model = S.Struct({
  tabs: Ui.Tabs.Model,
  // ...your other fields
})

// In your init function, initialize the Tabs Submodel with a unique id:
const init = () => [
  {
    tabs: Ui.Tabs.init({ id: 'framework-tabs' }),
    // ...your other fields
  },
  [],
]

// Embed the Tabs Message in your parent Message:
const GotTabsMessage = m('GotTabsMessage', {
  message: Ui.Tabs.Message,
})

// Declare a typed Tabs factory once at module scope. The Value generic
// types tab.value in toView so the consumer can switch on it without
// casting:
type Framework = 'Foldkit' | 'React' | 'Elm'
const FrameworkTabs = Ui.Tabs.create<Framework>()

const frameworks: ReadonlyArray<Framework> = ['Foldkit', 'React', 'Elm']

const descriptions: Record<Framework, string> = {
  Foldkit: 'Model-View-Update with Effect.',
  React: 'Component-based with hooks.',
  Elm: 'The original MVU architecture.',
}

// Inside your update function's M.tagsExhaustive({...}), delegate to
// FrameworkTabs.update. The OutMessage's `Selected` carries both the
// chosen value (typed as `Framework`) and its index. Lift either to
// domain state, route, or trigger a side effect.
GotTabsMessage: ({ message }) => {
  const [nextTabs, commands, maybeOutMessage] = FrameworkTabs.update(
    model.tabs,
    message,
  )
  const mappedCommands = Command.mapMessages(commands, message =>
    GotTabsMessage({ message }),
  )

  return Option.match(maybeOutMessage, {
    onNone: () => [evo(model, { tabs: () => nextTabs }), mappedCommands],
    onSome: M.type<Ui.Tabs.OutMessage<Framework>>().pipe(
      M.tagsExhaustive({
        Selected: ({ value, index }) => [
          // The child has emitted `Selected`. The body commits the
          // child's next state as usual. In this arm the parent can
          // also update its own state or dispatch its own Commands,
          // for example route to a new URL, persist the selection,
          // or trigger a panel content fetch.
          evo(model, { tabs: () => nextTabs }),
          mappedCommands,
        ],
      }),
    ),
  })
}

// Inside your view function, embed the tabs via h.submodel:
const view = () => {
  const h = html<Message>()

  return h.submodel({
    slotId: 'framework-tabs',
    model: model.tabs,
    view: FrameworkTabs.view,
    viewInputs: {
      tabs: frameworks,
      ariaLabel: 'Framework comparison',
      toView: ({ tablist, tabs, activeIndex }) =>
        h.div(
          [],
          [
            h.div(
              [...tablist, h.Class('flex')],
              tabs.map(tab =>
                h.button(
                  [
                    ...tab.tab,
                    h.Class(
                      'px-4 py-2 rounded-t-lg border data-[selected]:bg-white data-[selected]:border-b-0',
                    ),
                  ],
                  [h.span([], [tab.value])],
                ),
              ),
            ),
            ...tabs
              .filter(tab => tab.index === activeIndex)
              .map(tab =>
                h.div(
                  [...tab.panel, h.Class('p-6 border rounded-b-lg')],
                  [h.p([], [descriptions[tab.value]])],
                ),
              ),
          ],
        ),
    },
    toParentMessage: message => GotTabsMessage({ message }),
  })
}

Vertical

Pass orientation: 'Vertical' to switch to up/down arrow navigation.

Model-View-Update with Effect. A single immutable model holds all state, messages describe what happened, and a pure update function produces the next state. Side effects are explicit Commands, never hidden in the view layer.

Composable “The Elm Architecture” modules, Schema-typed state, and controlled side effects via Effect.

// Pseudocode walkthrough using the same Model, init, Message, and update
// as the basic tabs; only the view config changes to set orientation and
// use flex + flex-col for layout.
import { Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'

const GotTabsMessage = m('GotTabsMessage', {
  message: Ui.Tabs.Message,
})

type Framework = 'Foldkit' | 'React' | 'Elm'
const FrameworkTabs = Ui.Tabs.create<Framework>()
const frameworks: ReadonlyArray<Framework> = ['Foldkit', 'React', 'Elm']

const descriptions: Record<Framework, string> = {
  Foldkit: 'Model-View-Update with Effect.',
  React: 'Component-based with hooks.',
  Elm: 'The original MVU architecture.',
}

// Inside your view function, set orientation to 'Vertical' and use flex +
// flex-col for layout:
const view = (model: Model) => {
  const h = html<Message>()

  return h.submodel({
    slotId: 'framework-tabs',
    model: model.tabs,
    view: FrameworkTabs.view,
    viewInputs: {
      tabs: frameworks,
      ariaLabel: 'Framework comparison',
      orientation: 'Vertical',
      toView: ({ tablist, tabs, activeIndex }) =>
        h.div(
          [h.Class('flex')],
          [
            h.div(
              [...tablist, h.Class('flex flex-col')],
              tabs.map(tab =>
                h.button(
                  [
                    ...tab.tab,
                    h.Class(
                      'px-4 py-2 text-left rounded-l-lg border mr-[-1px] data-[selected]:bg-white data-[selected]:border-r-0',
                    ),
                  ],
                  [h.span([], [tab.value])],
                ),
              ),
            ),
            ...tabs
              .filter(tab => tab.index === activeIndex)
              .map(tab =>
                h.div(
                  [...tab.panel, h.Class('flex-1 p-6 border rounded-r-lg')],
                  [h.p([], [descriptions[tab.value]])],
                ),
              ),
          ],
        ),
    },
    toParentMessage: message => GotTabsMessage({ message }),
  })
}

Styling

Tabs is headless. The toView callback owns all tab and panel markup, spreading the attribute bundles from each TabInfo onto the consumer's elements. A common styling trick is to use a negative margin (mb-[-1px] for horizontal, mr-[-1px] for vertical) on the active tab to overlap the panel border.

AttributeCondition
data-selectedPresent on the active tab button and its panel.
data-disabledPresent on disabled tab buttons.

Keyboard Interaction

Tabs uses roving tabindex: only the focused tab is in the tab order. Arrow direction depends on orientation: left/right for horizontal, up/down for vertical. Disabled tabs are skipped during navigation.

KeyDescription
Arrow Right / DownMove to the next tab. In Automatic mode, also selects it.
Arrow Left / UpMove to the previous tab. In Automatic mode, also selects it.
HomeMove to the first tab.
EndMove to the last tab.
Enter / SpaceSelect the focused tab (Manual mode only; Automatic selects on focus).

Accessibility

The tab list receives role="tablist" with aria-orientation and aria-label. Each tab button gets role="tab" with aria-selected and aria-controls linking to its panel. Panels receive role="tabpanel" with aria-labelledby pointing back to the tab.

API Reference

InitConfig

Configuration object passed to Tabs.init().

NameTypeDefaultDescription
idstring-Unique ID for the tabs instance.
activeIndexnumber0Initially active tab index.
activationMode'Automatic' | 'Manual''Automatic'In Automatic mode, arrow keys select tabs on focus. In Manual mode, arrow keys focus only. Enter or Space is required to select.

ViewConfig

Configuration object passed to Tabs.view().

NameTypeDefaultDescription
modelTabs.Model-The tabs state from your parent Model.
toParentMessage(childMessage: Tabs.Message) => ParentMessage-Wraps Tabs Messages in your parent Message type for Submodel delegation.
tabsReadonlyArray<Value>-The list of tab values, in display order. When the tabs component is declared via `Ui.Tabs.create<MyUnion>()`, `Value` is your union type and each `TabInfo.value` is typed as `MyUnion`.
ariaLabelstring-Accessible label for the tab list.
toView(render: RenderInfo<Value>) => Html-Callback that receives the `tablist` attribute bundle, one `TabInfo<Value>` per tab, and the current `activeIndex`. Returns the composed layout.
isTabDisabled(value: Value, index: number) => boolean-Disables individual tabs.
orientation'Horizontal' | 'Vertical''Horizontal'Controls arrow key direction and `aria-orientation`. Horizontal uses left/right, vertical uses up/down.

RenderInfo

Payload delivered to the toView callback each render.

NameTypeDefaultDescription
tablistReadonlyArray<ChildAttribute>-Spread onto the tab list container. Includes `role="tablist"`, `aria-orientation`, and `aria-label`.
tabsReadonlyArray<TabInfo<Value>>-One entry per tab in `viewInputs.tabs`, in the same order. See TabInfo below.
activeIndexnumber-The currently-active tab index. Convenient when the consumer wants to render only the active panel (vs all panels with `hidden` for transitions).

TabInfo

Each entry in RenderInfo.tabs. Carries the value, derived state flags, and attribute bundles for the tab button and its panel.

NameTypeDefaultDescription
valueValue-The tab value. Typed as your `Value` union when the tabs component is declared via `Ui.Tabs.create<Value>()`.
indexnumber-Position in the `tabs` array.
isActiveboolean-Whether this tab is currently active.
isFocusedboolean-Whether this tab owns the roving tabindex (the one in the tab order).
isDisabledboolean-Whether this tab is disabled via `isTabDisabled`.
tabReadonlyArray<ChildAttribute>-Spread onto the tab button element. Includes `role="tab"`, `type="button"`, `aria-selected`, `aria-controls`, `tabindex`, the click handler, and the keyboard handler.
panelReadonlyArray<ChildAttribute>-Spread onto the tab panel element. Includes `role="tabpanel"`, `aria-labelledby` pointing back to the tab, and `tabindex`.

OutMessage

Messages emitted to the parent through the third element of [Model, Commands, Option<OutMessage>]. Pattern-match on the OutMessage in your update handler.

NameTypeDefaultDescription
Selected{ value: Value; index: number }-Emitted when a tab is committed via click or keyboard. Carries both the tab’s value (typed as your `Value` union via `Tabs.create<Value>()`) and its index. Pattern-match the third tuple element of Tabs.update in your GotTabsMessage handler.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson