Skip to main content
On this pageOverview

Checkbox

Overview

A toggle with checked, unchecked, and indeterminate states. Checkbox is a stateless controlled render helper: call it directly with a ViewConfig in your own view; no Model, update, or h.submodel wrapping. Your Model owns the checked value, you pass it in as isChecked, and onToggle dispatches a Message when the user toggles it. In your update handler, just store the value. For an on/off toggle that represents an immediate action (like a light switch), use Switch instead.

See it in an app

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

Examples

Basic

The checkbox element is typically a <button>. Spread attributes.checkbox onto it for role, ARIA state, and keyboard/click handlers. The label click handler also toggles the checkbox.

You agree to our Terms of Service and Privacy Policy.

// 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 { Schema as S } from 'effect'
import type { HtmlBuilder } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Checkbox } from '@foldkit/ui'

// Store the checked state as a plain boolean field in your Model:
const Model = S.Struct({
  acceptedTerms: S.Boolean,
  // ...your other fields
})

// In your init function, start it unchecked:
const init = () => [
  {
    acceptedTerms: false,
    // ...your other fields
  },
  [],
]

// A verb-first, past-tense Message carries the new checked state:
const ToggledTerms = m('ToggledTerms', { isChecked: S.Boolean })

const Message = S.Union([ToggledTerms])

// Inside your update function's M.tagsExhaustive({...}), store the value.
// This is the moment to fire analytics, validate a form, or push the value
// to a backend.
ToggledTerms: ({ isChecked }) => [
  evo(model, { acceptedTerms: () => isChecked }),
  [],
]

// Inside your view function, render the checkbox with Checkbox.view. It reads
// the checked state from your Model and calls onToggle with the new state.
const view = (model, h: HtmlBuilder<Message>) =>
  Checkbox.view(
    {
      id: 'accept-terms',
      isChecked: model.acceptedTerms,
      onToggle: isChecked => ToggledTerms({ isChecked }),
      toView: attributes =>
        h.div(
          [h.Class('flex flex-col gap-1')],
          [
            h.div(
              [h.Class('flex items-center gap-2')],
              [
                h.button(
                  [...attributes.checkbox, h.Class('h-5 w-5 rounded border')],
                  model.acceptedTerms ? ['✓'] : [],
                ),
                h.label(
                  [...attributes.label, h.Class('text-sm')],
                  ['Accept terms and conditions'],
                ),
              ],
            ),
            h.p(
              [...attributes.description, h.Class('text-sm text-gray-500')],
              ['You agree to our Terms of Service.'],
            ),
          ],
        ),
    },
    h,
  )

Indeterminate

Pass isIndeterminate: true to show a mixed state. This is typically computed from child checkbox states: when some but not all children are checked, the parent shows the indeterminate mark. Toggling the parent sets all children to the same state.

// 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 { Schema as S } from 'effect'
import type { HtmlBuilder } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Checkbox } from '@foldkit/ui'

// Store each child's checked state as a plain boolean field in your Model:
const Model = S.Struct({
  optionA: S.Boolean,
  optionB: S.Boolean,
  // ...your other fields
})

// In your init function, start each unchecked:
const init = () => [
  {
    optionA: false,
    optionB: false,
    // ...your other fields
  },
  [],
]

// One Message per child, plus one for the "Select All" parent. Each carries
// the new checked state:
const ToggledSelectAll = m('ToggledSelectAll', { isChecked: S.Boolean })
const ToggledOptionA = m('ToggledOptionA', { isChecked: S.Boolean })
const ToggledOptionB = m('ToggledOptionB', { isChecked: S.Boolean })

const Message = S.Union([ToggledSelectAll, ToggledOptionA, ToggledOptionB])

// Inside your update function's M.tagsExhaustive({...}), toggling "Select All"
// writes the same value to every child:
ToggledSelectAll: ({ isChecked }) => [
  evo(model, {
    optionA: () => isChecked,
    optionB: () => isChecked,
  }),
  [],
]

// Inside your view function, compute the parent's checked and indeterminate
// state from the children and pass isIndeterminate straight to Checkbox.view:
const view = (model, h: HtmlBuilder<Message>) => {
  const isAllChecked = model.optionA && model.optionB
  const isNoneChecked = !model.optionA && !model.optionB
  const isIndeterminate = !isAllChecked && !isNoneChecked

  const resolveSelectAllMark = () => {
    if (isIndeterminate) {
      return ['—']
    } else if (isAllChecked) {
      return ['✓']
    } else {
      return []
    }
  }

  return Checkbox.view(
    {
      id: 'select-all',
      isChecked: isAllChecked,
      isIndeterminate,
      onToggle: isChecked => ToggledSelectAll({ isChecked }),
      toView: attributes =>
        h.div(
          [h.Class('flex items-center gap-2')],
          [
            h.button(
              [...attributes.checkbox, h.Class('h-5 w-5 rounded border')],
              resolveSelectAllMark(),
            ),
            h.label(
              [...attributes.label, h.Class('text-sm')],
              ['All notifications'],
            ),
          ],
        ),
    },
    h,
  )
}

Styling

Checkbox is headless. Your toView callback controls all markup and styling. Use the data attributes below to style checked, indeterminate, disabled, and read-only states.

AttributeCondition
data-checkedPresent when checked and not indeterminate.
data-indeterminatePresent when isIndeterminate is true.
data-disabledPresent when isDisabled is true.
data-readonlyPresent when isReadOnly is true.

Keyboard Interaction

KeyDescription
SpaceToggles the Checkbox when isDisabled and isReadOnly are both false.

Accessibility

The checkbox element receives role="checkbox" and aria-checked which is set to "true", "false", or "mixed" depending on the checked and indeterminate state. The label is linked via aria-labelledby and the description via aria-describedby.

The label attribute group includes an id (accessible via Checkbox.labelId(id)) and the description group includes an id (accessible via Checkbox.descriptionId(id)), so a consumer can reference either element without re-declaring the naming convention.

isReadOnly and isDisabled both stop the Checkbox from reacting to clicks and Space. They differ in the semantics exposed to assistive technology, so they are not interchangeable.

aria-disabled="true", which isDisabled emits, communicates that the Checkbox is unavailable. aria-readonly="true", which isReadOnly emits, communicates that its value cannot be changed but remains relevant to the user. Both states keep tabindex="0", following Foldkit's convention that unavailable controls remain discoverable by keyboard and assistive technology.

Assistive technology support for aria-readonly on checkboxes varies. Pair it with a visible read-only treatment or explanatory text when users must distinguish it from disabled, and test the browser and assistive technology combinations your app supports.

Use isReadOnly when the checked state is still information the user needs, such as a decision that was already made, and isDisabled when the Checkbox is unavailable.

The two flags are independent. Setting both emits both sets of attributes, and either one on its own removes the click and Space handlers.

API Reference

ViewConfig

Configuration object passed to Checkbox.view().

NameTypeDefaultDescription
idstringUnique ID for the checkbox instance. Used to link the label and description via ARIA.
isCheckedbooleanThe current checked state, read from your Model. aria-checked and the data-checked marker derive from it.
onToggle(isChecked: boolean) => MessageMaps the new checked state to a Message when the user toggles the checkbox. Your update handler just stores the value.
toView(attributes: CheckboxAttributes) => HtmlCallback that receives attribute groups for the checkbox, label, description, and hidden input elements.
isDisabledbooleanfalseWhether the checkbox is disabled.
isReadOnlybooleanfalseWhether the checkbox is readable but not toggleable. Carries aria-readonly rather than aria-disabled. Independent of isDisabled.
isIndeterminatebooleanfalseWhether to show the indeterminate (mixed) state. Useful for "select all" checkboxes where some but not all children are checked.
namestringForm field name. When provided, a hidden input is included for native form submission.
valuestring'on'Value sent in the form when checked.

CheckboxAttributes

Attribute groups provided to the toView callback.

NameTypeDefaultDescription
checkboxReadonlyArray<Attribute<Message>>Spread onto the checkbox element (typically a <button>). Includes role, aria-checked, tabindex, click/keyboard handlers, and type="button" so a control inside a form does not submit it.
labelReadonlyArray<Attribute<Message>>Spread onto the label element. Includes an id for aria-labelledby and a click handler that toggles the checkbox.
descriptionReadonlyArray<Attribute<Message>>Spread onto a description element. Includes an id referenced by aria-describedby on the checkbox.
hiddenInputReadonlyArray<Attribute<Message>>Spread onto a hidden <input> for form submission. Only needed when the name prop is set.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson