Skip to content
Foldkit UI

Style @foldkit/ui with Pleat

Foldkit UI handles behavior and accessibility, and describes each part’s state with data attributes. Pleat’s When conditions select those attributes, so a part’s states live in its recipe and the view only spreads attributes.

The pattern

  • One recipe per part. A Menu has a trigger, a panel, and items; each gets its own style, and parts that look alike share one.
  • State through conditions, not the view. A highlighted item is When.highlighted, an open trigger is When.open. The view never picks a class from the Model to show a state.
  • Relations for children. A switch thumb or a chevron has no state of its own. When.within(marker, When.checked) moves it when the part it sits in changes.
  • Transitions as states. The open look is the base, the closed look sits under When.closed, and the transition under When.transitioning.
  • Tokens and variants. Every color, space, and radius is a theme token, so the dark theme works without a line of its own. Variants are recipe props with a Schema, like tone or appearance.

The demos below share these parts. Switch to the dark theme at the top of the page to see the same styles under it.

kit.tsTypeScript
import { Color, Recipe, Style, When } from '@pleat/core'

import { focusRing, tokens } from '../../design.ts'

const { color, font, radius, shadow, space, text } = tokens

// BUTTONS

export const uiButton = Recipe.make({
  name: 'UiButton',
  base: Style.make({
    display: 'inline-flex',
    alignItems: 'center',
    gap: space[2],
    border: '1px solid transparent',
    borderRadius: radius.md,
    fontFamily: font.sans,
    fontWeight: 600,
    lineHeight: 1,
    cursor: 'pointer',
  }).pipe(
    Style.merge(focusRing),
    Style.when(When.motionSafe, {
      transition: 'background-color 140ms, border-color 140ms',
    }),
    Style.when(When.active, { translate: '0 1px' }),
    // Foldkit UI sets data-disabled and aria-disabled. Disabled outranks
    // hover and press, so these win whatever else holds.
    Style.when(When.disabled, {
      opacity: 0.5,
      cursor: 'not-allowed',
      translate: 'none',
    }),
  ),
  variants: {
    tone: {
      Primary: Style.make({
        backgroundColor: color.accent,
        color: color.onAccent,
      }).pipe(
        Style.when(When.hover, {
          backgroundColor: Color.darken(color.accent, 0.06),
        }),
      ),
      Neutral: Style.make({
        backgroundColor: color.surface,
        borderColor: color.line,
        color: color.ink,
      }).pipe(
        Style.when(When.hover, { borderColor: color.muted }),
        Style.when(When.open, { borderColor: color.accent }),
      ),
      Quiet: Style.make({
        backgroundColor: 'transparent',
        color: color.ink,
      }).pipe(
        Style.when(When.hover, { backgroundColor: color.sunken }),
      ),
    },
    size: {
      Small: {
        paddingBlock: space[2],
        paddingInline: space[3],
        fontSize: text.sm,
      },
      Medium: {
        paddingBlock: space[3],
        paddingInline: space[4],
        fontSize: text.md,
      },
    },
  },
  defaults: { tone: 'Neutral', size: 'Small' },
})

// TRIGGERS

export const trigger = When.marker('ui-trigger')

export const triggerButton = uiButton({ tone: 'Neutral' }).pipe(
  Style.merge(Style.mark(trigger)),
  Style.merge(
    Style.make({
      justifyContent: 'space-between',
      minWidth: '13rem',
      fontWeight: 500,
    }),
  ),
)

/** Points down, and up while the trigger it sits in is open. */
export const chevron = Style.make({
  width: 7,
  height: 7,
  marginTop: -3,
  borderRight: `1.5px solid ${color.muted}`,
  borderBottom: `1.5px solid ${color.muted}`,
  rotate: '45deg',
  flexShrink: 0,
}).pipe(
  Style.when(When.motionSafe, {
    transition: 'rotate 160ms, margin 160ms',
  }),
  Style.when(When.within(trigger, When.open), {
    rotate: '225deg',
    marginTop: 3,
  }),
)

// FLOATING PANELS

/** Menu, Listbox, and Combobox panels. Foldkit UI positions them inline, so this
 *  leaves position alone and styles the surface and its transition. */
export const panel = Style.make({
  minWidth: 'var(--button-width, 13rem)',
  padding: space[1],
  backgroundColor: color.surface,
  border: `1px solid ${color.line}`,
  borderRadius: radius.md,
  boxShadow: shadow.raised,
  outline: 'none',
  zIndex: 30,
  transformOrigin: 'top',
}).pipe(
  // Foldkit UI writes the side it placed the panel on to data-placement.
  Style.when(When.data('placement', 'top'), {
    transformOrigin: 'bottom',
  }),
  // The panel's open look is its base. data-closed holds at the start of
  // entering and the end of leaving, and data-transition holds throughout.
  Style.when(When.all(When.motionSafe, When.transitioning), {
    transition: 'opacity 150ms ease, scale 150ms ease',
  }),
  Style.when(When.closed, { opacity: 0, scale: '0.96' }),
)

// OPTIONS

export const option = When.marker('ui-option')

export const optionItem = Recipe.make({
  name: 'Option',
  base: Style.make({
    display: 'flex',
    alignItems: 'center',
    gap: space[2],
    paddingBlock: space[2],
    paddingInline: space[3],
    borderRadius: 6,
    fontSize: text.sm,
    color: color.ink,
    cursor: 'default',
    userSelect: 'none',
  }).pipe(
    Style.merge(Style.mark(option)),
    // data-active: the item the pointer or the arrow keys are on.
    Style.when(When.highlighted, { backgroundColor: color.sunken }),
    Style.when(When.disabled, { opacity: 0.45 }),
  ),
  variants: {
    tone: {
      Default: Style.empty,
      Danger: Style.make({ color: color.accent }).pipe(
        Style.when(When.highlighted, {
          backgroundColor: color.accentSoft,
        }),
      ),
    },
  },
  defaults: { tone: 'Default' },
})

/** A check mark that shows when the option it sits in is selected. */
export const optionCheck = Style.make({
  width: 5,
  height: 9,
  marginInlineStart: 'auto',
  marginTop: -3,
  borderRight: `2px solid ${color.accent}`,
  borderBottom: `2px solid ${color.accent}`,
  rotate: '45deg',
  opacity: 0,
}).pipe(
  Style.when(When.within(option, When.selected), { opacity: 1 }),
)

export const separator = Style.make({
  height: 1,
  marginBlock: space[1],
  marginInline: space[2],
  backgroundColor: color.line,
})

// FIELDS

export const field = Style.make({
  display: 'flex',
  flexDirection: 'column',
  alignItems: 'flex-start',
  gap: space[2],
})

export const fieldLabel = Style.make({
  fontSize: text.sm,
  fontWeight: 600,
  color: color.ink,
})

export const hint = Style.make({
  fontSize: text.xs,
  color: color.muted,
})

export const cluster = Style.make({
  display: 'flex',
  flexWrap: 'wrap',
  alignItems: 'center',
  gap: space[3],
})

export const column = Style.make({
  display: 'flex',
  flexDirection: 'column',
  gap: space[4],
  width: '100%',
  maxWidth: '26rem',
})

/** Content that fills a trigger or an option, with its ends pushed apart. */
export const spread = Style.make({
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'space-between',
  gap: space[3],
  width: '100%',
})

What Foldkit UI sets

Each condition below was checked against the attributes @foldkit/ui 0.167 actually renders, and a browser test drives a Menu by keyboard to confirm the styles apply.

ConditionSelectsFoldkit UI sets it on
When.open[data-open], [aria-expanded="true"]Menu, Listbox, and Combobox wrapper and trigger; Disclosure button and panel; Dialog; Tooltip trigger and panel; Popover button
When.highlighted[data-active]The Menu, Listbox, or Combobox item under the pointer or the arrow keys
When.selected[data-selected], [aria-selected="true"]Tabs tab and panel; Listbox and Combobox option
When.checked:checked, [data-checked], [aria-checked="true"]Switch; Checkbox; RadioGroup option
When.indeterminate:indeterminate, [data-indeterminate], [aria-checked="mixed"]A mixed Checkbox
When.disabled:disabled, [data-disabled], [aria-disabled="true"]Every component that can be disabled
When.invalid[data-invalid], [aria-invalid="true"]Select, Listbox, Combobox, Input
When.readonly[data-readonly], [aria-readonly="true"]Switch, Checkbox, Listbox, Combobox, RadioGroup
When.closed[data-closed]Animated panels at the start of entering and the end of leaving
When.transitioning[data-transition]Animated panels while they enter or leave
When.data("placement", side)[data-placement="top"]Anchored panels, by the side they open on
  • Dialog calls show(), not showModal(), so When.backdrop never applies. Style its backdrop part instead.
  • In a RadioGroup, data-active marks the option holding the tab stop even when the group has no focus. Use When.focusVisible there.
  • Inline styles beat classes. Foldkit UI positions floating panels inline, gives an open Menu or Listbox trigger position: relative and a z-index, and animates a Disclosure panel’s height inline.

Button

Button gives a button its type, tab stop, and disabled attributes. When.disabled outranks hover and press, so a disabled button stays still.

button.tsTypeScript
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Button } from '@foldkit/ui'
import { css } from '@pleat/foldkit'

import { Message, type Model } from '../../demo/foldkitUi.ts'
import { cluster, uiButton } from './kit.ts'

export const buttonDemo = (
  model: Model,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [...css(cluster)],
    [
      Button.view(
        {
          onClick: Message.ClickedSave(),
          toView: ({ button }) =>
            h.button(
              [
                ...button,
                ...css(
                  uiButton({ tone: 'Primary', size: 'Medium' }),
                ),
              ],
              [`Save (${model.saveCount})`],
            ),
        },
        h,
      ),
      Button.view(
        {
          onClick: Message.ClickedSave(),
          toView: ({ button }) =>
            h.button(
              [...button, ...css(uiButton({ size: 'Medium' }))],
              ['Save a copy'],
            ),
        },
        h,
      ),
      // isDisabled sets data-disabled and aria-disabled, and drops onClick.
      Button.view(
        {
          isDisabled: true,
          toView: ({ button }) =>
            h.button(
              [
                ...button,
                ...css(
                  uiButton({ tone: 'Primary', size: 'Medium' }),
                ),
              ],
              ['Publish'],
            ),
        },
        h,
      ),
    ],
  )

Switch

The track reads When.checked. The thumb sits inside a marked track and moves under When.within(track, When.checked). size is a variant.

Weekly digest
A summary of new patterns every Monday.
Mentions
When someone tags you in a comment.
Billing alerts
Required while you have an open invoice.
switch.tsTypeScript
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Switch } from '@foldkit/ui'
import { Recipe, Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'

import { Message, type Model } from '../../demo/foldkitUi.ts'
import { focusRing, tokens } from '../../design.ts'
import { column, hint } from './kit.ts'

const { color, space, text } = tokens

// PARTS

const track = When.marker('ui-switch')

const switchTrack = Recipe.make({
  name: 'Switch',
  base: Style.make({
    display: 'inline-flex',
    flexShrink: 0,
    padding: 2,
    border: 'none',
    borderRadius: 999,
    backgroundColor: color.line,
    cursor: 'pointer',
  }).pipe(
    Style.merge(Style.mark(track)),
    Style.merge(focusRing),
    Style.when(When.motionSafe, {
      transition: 'background-color 160ms',
    }),
    Style.when(When.checked, { backgroundColor: color.accent }),
    Style.when(When.disabled, {
      opacity: 0.5,
      cursor: 'not-allowed',
    }),
  ),
  variants: {
    size: {
      Small: { width: 32, height: 18 },
      Medium: { width: 44, height: 24 },
    },
  },
  defaults: { size: 'Medium' },
})

// The thumb has no state of its own: it moves when the track it sits in is checked.
const switchThumb = Style.make({
  height: '100%',
  aspectRatio: '1',
  borderRadius: 999,
  backgroundColor: color.surface,
  boxShadow: `0 1px 2px ${color.muted}`,
}).pipe(
  Style.when(When.motionSafe, { transition: 'translate 160ms' }),
  Style.when(When.within(track, When.checked), {
    translate: '100%',
  }),
)

const switchRow = Style.make({
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'space-between',
  gap: space[4],
})

const switchLabel = Style.make({
  fontSize: text.sm,
  fontWeight: 600,
  cursor: 'pointer',
})

// VIEW

type SwitchConfig = Readonly<{
  id: string
  label: string
  description: string
  isChecked: boolean
  onToggle: (isChecked: boolean) => Message
  isDisabled?: boolean
  size?: 'Small' | 'Medium'
}>

const labeledSwitch = (
  config: SwitchConfig,
  h: HtmlBuilder<Message>,
): Html =>
  Switch.view(
    {
      id: config.id,
      isChecked: config.isChecked,
      onToggle: config.onToggle,
      isDisabled: config.isDisabled ?? false,
      hasDescription: true,
      toView: attributes =>
        h.div(
          [...css(switchRow)],
          [
            h.div(
              [],
              [
                h.div(
                  [...attributes.label, ...css(switchLabel)],
                  [config.label],
                ),
                h.div(
                  [...attributes.description, ...css(hint)],
                  [config.description],
                ),
              ],
            ),
            h.button(
              [
                ...attributes.button,
                ...css(
                  switchTrack({ size: config.size ?? 'Medium' }),
                ),
              ],
              [h.span([...css(switchThumb)])],
            ),
          ],
        ),
    },
    h,
  )

export const switchDemo = (
  model: Model,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [...css(column)],
    [
      labeledSwitch(
        {
          id: 'ui-switch-digest',
          label: 'Weekly digest',
          description: 'A summary of new patterns every Monday.',
          isChecked: model.isDigestOn,
          onToggle: isChecked =>
            Message.ToggledDigest({ isChecked }),
        },
        h,
      ),
      labeledSwitch(
        {
          id: 'ui-switch-mentions',
          label: 'Mentions',
          description: 'When someone tags you in a comment.',
          isChecked: model.isMentionsOn,
          onToggle: isChecked =>
            Message.ToggledMentions({ isChecked }),
          size: 'Small',
        },
        h,
      ),
      labeledSwitch(
        {
          id: 'ui-switch-billing',
          label: 'Billing alerts',
          description: 'Required while you have an open invoice.',
          isChecked: true,
          onToggle: isChecked =>
            Message.ToggledMentions({ isChecked }),
          isDisabled: true,
        },
        h,
      ),
    ],
  )

Checkbox

A parent checkbox is mixed while some of its steps are done. Foldkit UI marks that with data-indeterminate and aria-checked="mixed", which When.indeterminate selects.

Finish the hem
Cut
Press
Hem
checkbox.tsTypeScript
import { Array } from 'effect'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Checkbox } from '@foldkit/ui'
import { Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'

import { Message, type Model, Step } from '../../demo/foldkitUi.ts'
import { focusRing, tokens } from '../../design.ts'

const { color, radius, space, text } = tokens

// PARTS

const box = When.marker('ui-checkbox')

const checkboxBox = Style.make({
  display: 'inline-grid',
  placeItems: 'center',
  flexShrink: 0,
  width: 18,
  height: 18,
  padding: 0,
  borderRadius: radius.sm,
  border: `1.5px solid ${color.muted}`,
  backgroundColor: color.surface,
  cursor: 'pointer',
}).pipe(
  Style.merge(Style.mark(box)),
  Style.merge(focusRing),
  Style.when(When.checked, {
    backgroundColor: color.accent,
    borderColor: color.accent,
  }),
  // A mixed checkbox gets data-indeterminate and aria-checked="mixed",
  // never data-checked, so it needs its own condition.
  Style.when(When.indeterminate, {
    backgroundColor: color.accent,
    borderColor: color.accent,
  }),
  Style.when(When.disabled, {
    opacity: 0.5,
    cursor: 'not-allowed',
  }),
)

const glyph = Style.make({ gridArea: '1 / 1', opacity: 0 })

const tick = Style.merge(
  glyph,
  Style.make({
    width: 5,
    height: 9,
    marginTop: -2,
    borderRight: `2px solid ${color.onAccent}`,
    borderBottom: `2px solid ${color.onAccent}`,
    rotate: '45deg',
  }),
).pipe(Style.when(When.within(box, When.checked), { opacity: 1 }))

const dash = Style.merge(
  glyph,
  Style.make({
    width: 8,
    height: 2,
    borderRadius: 1,
    backgroundColor: color.onAccent,
  }),
).pipe(
  Style.when(When.within(box, When.indeterminate), { opacity: 1 }),
)

const checkboxRow = Style.make({
  display: 'flex',
  alignItems: 'center',
  gap: space[3],
  fontSize: text.sm,
})

const checklist = Style.make({
  display: 'flex',
  flexDirection: 'column',
  gap: space[3],
})

const nested = Style.merge(
  checklist,
  Style.make({ paddingInlineStart: space[6] }),
)

// VIEW

type CheckboxConfig = Readonly<{
  id: string
  label: string
  isChecked: boolean
  isIndeterminate?: boolean
  onToggle: (isChecked: boolean) => Message
}>

const labeledCheckbox = (
  config: CheckboxConfig,
  h: HtmlBuilder<Message>,
): Html =>
  Checkbox.view(
    {
      id: config.id,
      isChecked: config.isChecked,
      isIndeterminate: config.isIndeterminate ?? false,
      onToggle: config.onToggle,
      toView: attributes =>
        h.div(
          [...css(checkboxRow)],
          [
            h.button(
              [...attributes.checkbox, ...css(checkboxBox)],
              [h.span([...css(tick)]), h.span([...css(dash)])],
            ),
            h.span([...attributes.label], [config.label]),
          ],
        ),
    },
    h,
  )

export const checkboxDemo = (
  model: Model,
  h: HtmlBuilder<Message>,
): Html => {
  const doneCount = model.doneSteps.length
  const isAllDone = doneCount === Step.literals.length
  return h.div(
    [...css(checklist)],
    [
      labeledCheckbox(
        {
          id: 'ui-checkbox-all',
          label: 'Finish the hem',
          isChecked: isAllDone,
          isIndeterminate: doneCount > 0 && !isAllDone,
          onToggle: () =>
            Message.ToggledAllSteps({ isChecked: !isAllDone }),
        },
        h,
      ),
      h.div(
        [...css(nested)],
        Step.literals.map(step =>
          labeledCheckbox(
            {
              id: `ui-checkbox-${step.toLowerCase()}`,
              label: step,
              isChecked: Array.contains(model.doneSteps, step),
              onToggle: isChecked =>
                Message.ToggledStep({ step, isChecked }),
            },
            h,
          ),
        ),
      ),
    ],
  )
}

Disclosure

The button and panel get data-open. The chevron is the shared one: it turns inside any open trigger.

data-open, data-active, data-selected, data-checked, data-disabled, and the ARIA states beside them. Each has a When condition of the same name.
disclosure.tsTypeScript
import { Array } from 'effect'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Disclosure } from '@foldkit/ui'
import { Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'

import {
  Message,
  type Model,
  type Question,
} from '../../demo/foldkitUi.ts'
import { focusRing, tokens } from '../../design.ts'
import { chevron, trigger } from './kit.ts'

const { color, radius, space, text } = tokens

// PARTS

const disclosureList = Style.make({
  width: '100%',
  maxWidth: '34rem',
  border: `1px solid ${color.line}`,
  borderRadius: radius.lg,
  backgroundColor: color.surface,
  overflow: 'hidden',
})

const disclosureItem = Style.empty.pipe(
  Style.when(When.not(When.firstChild), {
    borderTop: `1px solid ${color.line}`,
  }),
)

// The button carries data-open and aria-expanded, so the shared chevron,
// which turns inside an open trigger, works here unchanged.
const disclosureButton = Style.make({
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'space-between',
  gap: space[3],
  width: '100%',
  paddingBlock: space[3],
  paddingInline: space[4],
  border: 'none',
  backgroundColor: 'transparent',
  color: color.ink,
  fontFamily: 'inherit',
  fontSize: text.sm,
  fontWeight: 600,
  textAlign: 'start',
  cursor: 'pointer',
}).pipe(
  Style.merge(Style.mark(trigger)),
  Style.merge(focusRing),
  Style.when(When.hover, { backgroundColor: color.sunken }),
  Style.when(When.open, { color: color.accent }),
)

// animatePanel animates the height with an inline grid, so the panel only
// styles its padding and type.
const disclosurePanel = Style.make({
  paddingInline: space[4],
  paddingBottom: space[4],
  fontSize: text.sm,
  color: color.muted,
})

// VIEW

const QUESTIONS: ReadonlyArray<
  readonly [Question, string, string]
> = [
  [
    'Attributes',
    'Which attributes does Foldkit UI set?',
    'data-open, data-active, data-selected, data-checked, data-disabled, and the ARIA states beside them. Each has a When condition of the same name.',
  ],
  [
    'Transitions',
    'How do transitions work?',
    'Animated parts get data-closed at the start of entering and the end of leaving, with data-transition throughout. Style the open look as the base and the closed look under When.closed.',
  ],
  [
    'Inline',
    'Is there anything Pleat can’t style?',
    'Inline styles win over classes. Foldkit UI positions floating panels inline, and animates this panel’s height inline, so leave those properties to it.',
  ],
]

export const disclosureDemo = (
  model: Model,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [...css(disclosureList)],
    QUESTIONS.map(([question, title, answer]) =>
      Disclosure.view(
        {
          id: `ui-disclosure-${question.toLowerCase()}`,
          isOpen: Array.contains(model.openQuestions, question),
          onToggle: isOpen =>
            Message.ToggledQuestion({ question, isOpen }),
          toView: ({ button, panel, animatePanel }) =>
            h.div(
              [...css(disclosureItem)],
              [
                h.button(
                  [...button, ...css(disclosureButton)],
                  [title, h.span([...css(chevron)])],
                ),
                animatePanel(
                  h.div(
                    [...panel, ...css(disclosurePanel)],
                    [answer],
                  ),
                ),
              ],
            ),
        },
        h,
      ),
    ),
  )

Tabs

The selected tab and its panel get data-selected. Both the list and the tabs take an appearance variant, and the Model holds the choice as a Schema literal.

appearance
Six pieces, cut on the grain. Seam allowance is 1.5 cm throughout.
tabs.tsTypeScript
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Recipe, Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'

import {
  Message,
  type Model,
  Section,
  SectionTabs,
} from '../../demo/foldkitUi.ts'
import { focusRing, tokens } from '../../design.ts'

const { color, radius, space, text } = tokens

// PARTS

// appearance is a Schema-typed prop: the Model holds a TabAppearance, and
// tab.schema would reject anything else.
const tabList = Recipe.make({
  name: 'TabList',
  base: Style.make({
    display: 'flex',
    gap: space[1],
    overflowX: 'auto',
  }),
  variants: {
    appearance: {
      Underline: { borderBottom: `1px solid ${color.line}` },
      Pill: {
        padding: 3,
        borderRadius: radius.md,
        backgroundColor: color.sunken,
        border: `1px solid ${color.line}`,
      },
    },
  },
})

const tab = Recipe.make({
  name: 'Tab',
  base: Style.make({
    paddingBlock: space[2],
    paddingInline: space[3],
    border: 'none',
    backgroundColor: 'transparent',
    color: color.muted,
    fontFamily: 'inherit',
    fontSize: text.sm,
    fontWeight: 500,
    whiteSpace: 'nowrap',
    cursor: 'pointer',
  }).pipe(
    Style.merge(focusRing),
    Style.when(When.hover, { color: color.ink }),
    Style.when(When.disabled, {
      opacity: 0.45,
      cursor: 'not-allowed',
    }),
  ),
  variants: {
    appearance: {
      Underline: Style.make({
        marginBottom: -1,
        borderBottom: '2px solid transparent',
      }).pipe(
        Style.when(When.selected, {
          color: color.ink,
          borderBottomColor: color.accent,
        }),
      ),
      Pill: Style.make({ borderRadius: 6 }).pipe(
        Style.when(When.selected, {
          color: color.ink,
          backgroundColor: color.surface,
          boxShadow: `0 1px 2px ${color.line}`,
        }),
      ),
    },
  },
})

const tabPanel = Style.make({
  paddingBlock: space[4],
  fontSize: text.sm,
  color: color.muted,
}).pipe(Style.merge(focusRing))

// VIEW

const PANELS: Readonly<Record<Section, string>> = {
  Pattern:
    'Six pieces, cut on the grain. Seam allowance is 1.5 cm throughout.',
  Fabric:
    'Mid-weight linen or chambray, about 2.2 m at 140 cm wide.',
  Notions:
    'Seven buttons, interfacing for the placket, and matching thread.',
  History: 'No revisions yet.',
}

export const tabsDemo = (
  model: Model,
  h: HtmlBuilder<Message>,
): Html =>
  h.submodel({
    slotId: 'ui-tabs',
    model: model.tabs,
    view: SectionTabs.view,
    toParentMessage: message => Message.GotTabsMessage({ message }),
    viewInputs: {
      tabs: Section.literals,
      selectedValue: model.section,
      ariaLabel: 'Pattern details',
      isTabDisabled: section => section === 'History',
      toView: ({ tablist, tabs, activeIndex }) => {
        const appearance = model.tabAppearance
        return h.div(
          [],
          [
            h.div(
              [...tablist, ...css(tabList({ appearance }))],
              tabs.map(info =>
                h.button(
                  [...info.tab, ...css(tab({ appearance }))],
                  [info.value],
                ),
              ),
            ),
            ...tabs
              .filter(info => info.index === activeIndex)
              .map(info =>
                h.div(
                  [...info.panel, ...css(tabPanel)],
                  [PANELS[info.value]],
                ),
              ),
          ],
        )
      },
    },
  })

Select

Select wraps the native element. An empty choice is invalid, which sets data-invalid and aria-invalid; the second select is disabled through the native property.

Shown on the cutting list.
Set by the pattern.
select.tsTypeScript
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Select } from '@foldkit/ui'
import { Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'

import { Message, type Model } from '../../demo/foldkitUi.ts'
import { focusRing, tokens } from '../../design.ts'
import { chevron, cluster, field, fieldLabel, hint } from './kit.ts'

const { color, radius, space, text } = tokens

// PARTS

const selectControl = Style.make({
  appearance: 'none',
  minWidth: '13rem',
  paddingBlock: space[2],
  paddingInlineStart: space[3],
  paddingInlineEnd: space[6],
  border: `1px solid ${color.line}`,
  borderRadius: radius.md,
  backgroundColor: color.surface,
  color: color.ink,
  fontFamily: 'inherit',
  fontSize: text.sm,
  cursor: 'pointer',
}).pipe(
  Style.merge(focusRing),
  Style.when(When.hover, { borderColor: color.muted }),
  // isInvalid sets data-invalid and aria-invalid. Invalid outranks hover.
  Style.when(When.invalid, {
    borderColor: color.accent,
    boxShadow: `0 0 0 3px ${color.accentSoft}`,
  }),
  // A native select gets the disabled property, which :disabled matches.
  Style.when(When.disabled, {
    opacity: 0.5,
    cursor: 'not-allowed',
  }),
)

const selectBox = Style.make({
  position: 'relative',
  display: 'inline-flex',
})

const selectChevron = Style.merge(
  chevron,
  Style.make({
    position: 'absolute',
    insetInlineEnd: space[3],
    top: '50%',
    pointerEvents: 'none',
  }),
)

// VIEW

const WEIGHTS: ReadonlyArray<
  readonly [value: string, label: string]
> = [
  ['', 'Choose a weight'],
  ['Light', 'Light, under 150 gsm'],
  ['Medium', 'Medium, 150 to 250 gsm'],
  ['Heavy', 'Heavy, over 250 gsm'],
]

const weightSelect = (
  config: Readonly<{
    id: string
    label: string
    value: string
    isDisabled: boolean
  }>,
  h: HtmlBuilder<Message>,
): Html =>
  Select.view(
    {
      id: config.id,
      value: config.value,
      isDisabled: config.isDisabled,
      isInvalid: !config.isDisabled && config.value === '',
      hasDescription: true,
      onChange: weight => Message.UpdatedWeight({ weight }),
      toView: ({ select, label, description }) =>
        h.div(
          [...css(field)],
          [
            h.label([...label, ...css(fieldLabel)], [config.label]),
            h.div(
              [...css(selectBox)],
              [
                h.select(
                  [...select, ...css(selectControl)],
                  WEIGHTS.map(([value, text]) =>
                    h.option([h.Value(value)], [text]),
                  ),
                ),
                h.span([...css(selectChevron)]),
              ],
            ),
            h.span(
              [...description, ...css(hint)],
              [
                config.isDisabled
                  ? 'Set by the pattern.'
                  : 'Shown on the cutting list.',
              ],
            ),
          ],
        ),
    },
    h,
  )

export const selectDemo = (
  model: Model,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [...css(cluster)],
    [
      weightSelect(
        {
          id: 'ui-select-weight',
          label: 'Fabric weight',
          value: model.weight,
          isDisabled: false,
        },
        h,
      ),
      weightSelect(
        {
          id: 'ui-select-lining',
          label: 'Lining weight',
          value: 'Light',
          isDisabled: true,
        },
        h,
      ),
    ],
  )

Listbox

A custom select. Options get data-active while highlighted and data-selected when chosen, and the check mark appears through When.within(option, When.selected). Margaret is disabled.

Assignee
listbox.tsTypeScript
import { Option } from 'effect'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Style } from '@pleat/core'
import { css, cssClass } from '@pleat/foldkit'

import {
  AssigneeListbox,
  Message,
  type Model,
  Person,
} from '../../demo/foldkitUi.ts'
import { tokens } from '../../design.ts'
import {
  chevron,
  field,
  fieldLabel,
  optionCheck,
  optionItem,
  panel,
  spread,
  triggerButton,
} from './kit.ts'

const { color, text } = tokens

const avatar = Style.make({
  display: 'inline-grid',
  placeItems: 'center',
  width: 22,
  height: 22,
  borderRadius: 999,
  backgroundColor: color.accentSoft,
  color: color.accent,
  fontSize: text.xs,
  fontWeight: 700,
})

const person = Style.make({
  display: 'inline-flex',
  alignItems: 'center',
  gap: 8,
})

const personView = (name: Person, h: HtmlBuilder<Message>): Html =>
  h.span(
    [...css(person)],
    [h.span([...css(avatar)], [name.slice(0, 1)]), name],
  )

// The check mark in each option shows through When.within(option,
// When.selected), so itemToConfig never looks at isSelected.
export const listboxDemo = (
  model: Model,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [...css(field)],
    [
      h.span(
        [h.Id('ui-listbox-label'), ...css(fieldLabel)],
        ['Assignee'],
      ),
      h.submodel({
        slotId: 'ui-listbox',
        model: model.listbox,
        view: AssigneeListbox.view,
        toParentMessage: message =>
          Message.GotListboxMessage({ message }),
        viewInputs: {
          items: Person.literals,
          maybeSelectedValue: model.maybeAssignee,
          ariaLabelledBy: 'ui-listbox-label',
          isItemDisabled: name => name === 'Margaret',
          buttonContent: h.span(
            [...css(spread)],
            [
              Option.match(model.maybeAssignee, {
                onNone: () => h.span([], ['Nobody']),
                onSome: name => personView(name, h),
              }),
              h.span([...css(chevron)]),
            ],
          ),
          buttonClassName: cssClass(triggerButton),
          itemsClassName: cssClass(panel),
          itemToConfig: name => ({
            className: cssClass(optionItem({})),
            content: h.span(
              [...css(spread)],
              [personView(name, h), h.span([...css(optionCheck)])],
            ),
          }),
          anchor: { placement: 'bottom-start', gap: 6 },
        },
      }),
    ],
  )

Combobox

Type to filter. The panel is the same one Menu and Listbox use, with the same enter and leave transition.

combobox.tsTypeScript
import { Option, String } from 'effect'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Style, When } from '@pleat/core'
import { css, cssClass } from '@pleat/foldkit'

import {
  Fabric,
  FabricCombobox,
  Message,
  type Model,
} from '../../demo/foldkitUi.ts'
import { tokens } from '../../design.ts'
import {
  chevron,
  field,
  fieldLabel,
  optionCheck,
  optionItem,
  panel,
  spread,
  trigger,
} from './kit.ts'

const { color, radius, space, text } = tokens

// PARTS

const comboboxField = Style.make({
  display: 'flex',
  alignItems: 'center',
  minWidth: '15rem',
  border: `1px solid ${color.line}`,
  borderRadius: radius.md,
  backgroundColor: color.surface,
}).pipe(
  Style.when(When.hover, { borderColor: color.muted }),
  Style.when(When.focusWithin, {
    borderColor: color.accent,
    boxShadow: `0 0 0 3px ${color.accentSoft}`,
  }),
)

const comboboxInput = Style.make({
  flexGrow: 1,
  minWidth: 0,
  paddingBlock: space[2],
  paddingInline: space[3],
  border: 'none',
  outline: 'none',
  backgroundColor: 'transparent',
  color: color.ink,
  fontFamily: 'inherit',
  fontSize: text.sm,
}).pipe(Style.when(When.placeholder, { color: color.muted }))

// The toggle carries aria-expanded, so the shared chevron turns inside it.
const comboboxToggle = Style.make({
  display: 'inline-grid',
  placeItems: 'center',
  alignSelf: 'stretch',
  paddingInline: space[3],
  border: 'none',
  backgroundColor: 'transparent',
  cursor: 'pointer',
}).pipe(Style.merge(Style.mark(trigger)))

// VIEW

export const comboboxDemo = (
  model: Model,
  h: HtmlBuilder<Message>,
): Html => {
  const query = String.toLowerCase(model.combobox.inputValue)
  const matches = Fabric.literals.filter(fabric =>
    String.includes(query)(String.toLowerCase(fabric)),
  )
  return h.div(
    [...css(field)],
    [
      h.label(
        [h.For('ui-combobox-input'), ...css(fieldLabel)],
        ['Fabric'],
      ),
      h.submodel({
        slotId: 'ui-combobox',
        model: model.combobox,
        view: FabricCombobox.view,
        toParentMessage: message =>
          Message.GotComboboxMessage({ message }),
        viewInputs: {
          items: matches,
          maybeSelectedValue: model.maybeFabric,
          restingInputValue: Option.getOrElse(
            model.maybeFabric,
            () => '',
          ),
          itemToValue: fabric => fabric,
          itemToDisplayText: fabric => fabric,
          isItemDisabled: fabric => fabric === 'Organza',
          inputPlaceholder: 'Search fabrics',
          inputWrapperClassName: cssClass(comboboxField),
          inputClassName: cssClass(comboboxInput),
          buttonContent: h.span([...css(chevron)]),
          buttonClassName: cssClass(comboboxToggle),
          itemsClassName: cssClass(panel),
          itemToConfig: fabric => ({
            className: cssClass(optionItem({})),
            content: h.span(
              [...css(spread)],
              [fabric, h.span([...css(optionCheck)])],
            ),
          }),
          anchor: { placement: 'bottom-start', gap: 6 },
        },
      }),
    ],
  )
}

Dialog

The backdrop and the panel share the transition states. The dialog lays itself out only under When.open, since a closed dialog is hidden by the browser’s own stylesheet.

Not archived.

Archive this pattern?

It leaves your library, and anyone it was shared with loses access.

dialog.tsTypeScript
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Dialog } from '@foldkit/ui'
import { Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'

import { Message, type Model } from '../../demo/foldkitUi.ts'
import { tokens } from '../../design.ts'
import { cluster, hint, uiButton } from './kit.ts'

const { color, font, radius, shadow, space, text } = tokens

// PARTS

// A closed <dialog> is hidden by the browser's own stylesheet, which any
// author rule beats, so the layout applies only once Foldkit UI sets data-open.
const dialogRoot = Style.empty.pipe(
  Style.when(When.open, {
    display: 'grid',
    placeItems: 'center',
    padding: space[4],
  }),
)

const fade = When.all(When.motionSafe, When.transitioning)

// Foldkit UI calls show(), not showModal(), so there is no ::backdrop. The
// backdrop is an element of its own, with the same transition states as the panel.
const dialogBackdrop = Style.make({
  position: 'fixed',
  inset: 0,
  // A scrim darkens in either theme, so it is the one color here that is not a token.
  backgroundColor: 'oklch(18% 0.02 272 / 0.5)',
}).pipe(
  Style.when(fade, { transition: 'opacity 200ms ease' }),
  Style.when(When.closed, { opacity: 0 }),
)

const dialogPanel = Style.make({
  position: 'relative',
  display: 'flex',
  flexDirection: 'column',
  gap: space[3],
  width: '100%',
  maxWidth: '26rem',
  padding: space[5],
  borderRadius: radius.lg,
  border: `1px solid ${color.line}`,
  backgroundColor: color.surface,
  color: color.ink,
  boxShadow: shadow.raised,
}).pipe(
  Style.when(fade, {
    transition:
      'opacity 200ms ease, translate 200ms ease, scale 200ms ease',
  }),
  Style.when(When.closed, {
    opacity: 0,
    translate: '0 8px',
    scale: '0.97',
  }),
)

const dialogTitle = Style.make({
  fontFamily: font.sans,
  fontSize: text.xl,
  fontWeight: 600,
})

const dialogText = Style.make({
  fontSize: text.sm,
  color: color.muted,
})

const dialogActions = Style.merge(
  cluster,
  Style.make({ justifyContent: 'flex-end' }),
)

// VIEW

export const dialogDemo = (
  model: Model,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [...css(cluster)],
    [
      model.isPatternArchived
        ? h.button(
            [
              h.Type('button'),
              h.OnClick(Message.ClickedRestore()),
              ...css(uiButton({})),
            ],
            ['Restore pattern'],
          )
        : h.button(
            [
              h.Type('button'),
              h.OnClick(Message.ClickedArchivePrompt()),
              ...css(uiButton({})),
            ],
            ['Archive pattern…'],
          ),
      h.span(
        [...css(hint)],
        [model.isPatternArchived ? 'Archived.' : 'Not archived.'],
      ),
      h.submodel({
        slotId: 'ui-dialog',
        model: model.dialog,
        view: Dialog.view,
        toParentMessage: message =>
          Message.GotDialogMessage({ message }),
        viewInputs: {
          hasDescription: true,
          toView: ({
            dialog,
            backdrop,
            panel,
            title,
            description,
            closeButton,
            initialFocus,
          }) =>
            h.dialog(
              [...dialog, ...css(dialogRoot)],
              [
                h.div([...backdrop, ...css(dialogBackdrop)]),
                h.div(
                  [...panel, ...css(dialogPanel)],
                  [
                    h.h2(
                      [...title, ...css(dialogTitle)],
                      ['Archive this pattern?'],
                    ),
                    h.p(
                      [...description, ...css(dialogText)],
                      [
                        'It leaves your library, and anyone it was shared with loses access.',
                      ],
                    ),
                    h.div(
                      [...css(dialogActions)],
                      [
                        h.button(
                          [
                            ...closeButton,
                            ...initialFocus,
                            ...css(uiButton({ size: 'Medium' })),
                          ],
                          ['Keep it'],
                        ),
                        h.button(
                          [
                            h.Type('button'),
                            h.OnClick(Message.ClickedArchive()),
                            ...css(
                              uiButton({
                                tone: 'Primary',
                                size: 'Medium',
                              }),
                            ),
                          ],
                          ['Archive'],
                        ),
                      ],
                    ),
                  ],
                ),
              ],
            ),
        },
      }),
    ],
  )

Tooltip

Hover or focus the button. Tooltip has no transition states, so its panel plays a keyframe animation when it mounts.

tooltip.tsTypeScript
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Tooltip } from '@foldkit/ui'
import { Global, Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'

import { Message, type Model } from '../../demo/foldkitUi.ts'
import { tokens } from '../../design.ts'
import { uiButton } from './kit.ts'

const { color, radius, space, text } = tokens

// PARTS

// Tooltip has no transition states: its panel mounts when it opens. A
// keyframe animation plays on mount instead.
const rise = Global.keyframes('ui-tooltip-rise', {
  from: { opacity: 0, translate: '0 3px' },
  to: { opacity: 1, translate: '0 0' },
})

const tooltipPanel = Style.make({
  maxWidth: '16rem',
  paddingBlock: space[1],
  paddingInline: space[2],
  borderRadius: radius.sm,
  backgroundColor: color.ink,
  color: color.canvas,
  fontSize: text.xs,
  lineHeight: 1.4,
  zIndex: 40,
}).pipe(
  Style.when(When.motionSafe, {
    animation: `${rise} 140ms ease-out`,
  }),
)

// VIEW

export const tooltipDemo = (
  model: Model,
  h: HtmlBuilder<Message>,
): Html =>
  h.submodel({
    slotId: 'ui-tooltip',
    model: model.tooltip,
    view: Tooltip.view,
    toParentMessage: message =>
      Message.GotTooltipMessage({ message }),
    viewInputs: {
      anchor: { placement: 'top', gap: 6 },
      toView: ({ trigger, panel, isVisible }) =>
        h.span(
          [],
          [
            h.button(
              [...trigger, ...css(uiButton({ size: 'Medium' }))],
              ['Copy link'],
            ),
            ...(isVisible
              ? [
                  h.div(
                    [...panel, ...css(tooltipPanel)],
                    ['Anyone with the link can view'],
                  ),
                ]
              : []),
          ],
        ),
    },
  })

Wire it into your app

Stateful components are Submodels: a field in your Model, a message that wraps theirs, Update.foldChild in update, and h.submodel in the view. Pleat appears only in the view.

wiring.tsTypeScript
import { Option, Schema } from 'effect'
import { Update } from 'foldkit'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { modifyFields } from 'foldkit/struct'

import { Menu } from '@foldkit/ui'
import { cssClass } from '@pleat/foldkit'

import { optionItem, panel, triggerButton } from './kit.ts'

const Action = Schema.Literals(['Rename', 'Archive'])
type Action = typeof Action.Type

const ActionMenu: Menu.Bundle<Action> = Menu.create<Action>()

// MODEL AND MESSAGE

export const Model = Schema.Struct({
  menu: Menu.Model,
  maybeAction: Schema.Option(Action),
})
export type Model = typeof Model.Type

export const Message = defineMessageUnion({
  GotMenuMessage: { message: Menu.Message },
})
export type Message = typeof Message.Type

export const init = (): Model => ({
  menu: Menu.init({ id: 'actions', isAnimated: true }),
  maybeAction: Option.none(),
})

// UPDATE

const foldMenu = Update.foldChild({
  update: ActionMenu.update,
  read: (model: Model) => Option.some(model.menu),
  write: (model, menu) => modifyFields(model, { menu: () => menu }),
  toParentMessage: message => Message.GotMenuMessage({ message }),
  foldOutMessage:
    ({ value }) =>
    model => ({
      model: modifyFields(model, {
        maybeAction: () => Option.some(value),
      }),
    }),
})

export const update = (
  model: Model,
  message: Message,
): Update.Return<Model, Message> =>
  Message.match(message, {
    GotMenuMessage: ({ message }) => foldMenu(model, message),
  })

// VIEW

// Pleat appears only here: class names for the parts, with every state
// (open, highlighted, disabled, closed) handled inside the styles.
export const view = (model: Model, h: HtmlBuilder<Message>): Html =>
  h.submodel({
    slotId: 'actions',
    model: model.menu,
    view: ActionMenu.view,
    toParentMessage: message => Message.GotMenuMessage({ message }),
    viewInputs: {
      items: Action.literals,
      buttonContent: h.span([], ['Actions']),
      buttonClassName: cssClass(triggerButton),
      itemsClassName: cssClass(panel),
      itemToConfig: action => ({
        className: cssClass(optionItem({})),
        content: h.span([], [action]),
      }),
    },
  })

Menu.create returns a bundle that TypeScript can’t name across files without an annotation, so the example gives it one: Menu.Bundle<Action>.