Theme
Design your own
On this page (9)
Component Skill — components/toggle/component-skill.md

Native basis

<button> element with aria-pressed. The browser provides click and keyboard

Web Platform APIs

<button>aria-pressedcolor-mix()prefers-reduced-motionprefers-contrastforced-colors

Classes

.toggle

Variants (data-variant)

outlineBorder + shadow, transparent fill

Sizes (data-size)

xsHeight: 1.75remsmHeight: 2remmdHeight: 2.25rem (default)lgHeight: 2.5remxlHeight: 3rem

Notes

• The toggle is just a button with aria-pressed - no custom elements needed.

• Icon-only toggles must have aria-label for screen readers.

• For toggle groups (e.g., text alignment), wrap in a container with role="group" and aria-label.

• The pressed state uses --accent / --accent-foreground to match shadcn conventions.

§Default

Transparent at rest. Background appears on hover and when pressed.

§Outline

Visible border and subtle shadow at rest.

§With Text

Icon and label together.

§Size

The full five-step scale via data-size - the default equals md.

§Disabled

Non-interactive at 50% opacity.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - unpressed (the authored aria-pressed value is restored by setState('default'))
  • pressed - aria-pressed="true", accent background

The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/toggle-{state}.png.

Machine contract - verified against toggle.schema.json by bun run verify:

StateTypeValuesDefaultDescription
pressedbooleantrue, falsefalsePressed state carried by aria-pressed (runtime writes it, CSS styles it).

§API

Generated from toggle.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type ToggleState = 'default' | 'pressed' - setState(name, config) takes the config of the state it names.

StateDescription
default
Not pressed (aria-pressed="false").

No config.

pressed
Pressed (aria-pressed="true").

No config.

Every element

MemberDescription
el.api.setState<S extends ToggleState>(name: S, config?: ToggleStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?ToggleStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: ToggleState; config: ToggleStateConfigs[ToggleState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: ToggleState; config: ToggleStateConfigs[ToggleState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: ToggleState; config: ToggleStateConfigs[ToggleState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: ToggleState; config: ToggleStateConfigs[ToggleState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: ToggleState; config: ToggleStateConfigs[ToggleState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.toggleApi.setState<S extends ToggleState>(el: HTMLElement, name: S, config?: ToggleStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?ToggleStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.toggleApi.getState(el: HTMLElement): { name: ToggleState; config: ToggleStateConfigs[ToggleState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: ToggleState; config: ToggleStateConfigs[ToggleState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.toggleApi.render(state: { name: ToggleState; config: ToggleStateConfigs[ToggleState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: ToggleState; config: ToggleStateConfigs[ToggleState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.toggleApi.store(el: HTMLElement): Store<{ name: ToggleState; config: ToggleStateConfigs[ToggleState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: ToggleState; config: ToggleStateConfigs[ToggleState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.toggleApi.commit<S extends ToggleState>(el: HTMLElement, name: S, config?: ToggleStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?ToggleStateConfigs[S]its config
df$.shadcn.toggleStates: ToggleState[]The declared states, 'default' first: default, pressed.

§CSS view file

/* -- Toggle component ------------------------------------------ */
@layer components {
  .toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    font-size: 0.875rem;
    font-weight: 500;
    font-family: var(--font-sans);
    line-height: 1;
    white-space: nowrap;
    border-radius: var(--radius-md);
    border: 1px solid transparent;
    background: transparent;
    color: var(--muted-foreground);
    cursor: pointer;
    transition: color 150ms ease, background-color 150ms ease, box-shadow 150ms ease;
    outline: none;
    flex-shrink: 0;
    height: 2.25rem;
    padding: 0 0.5rem;
    min-width: 2.25rem;
    &:hover:not(:disabled) {
      background-color: var(--muted);
      color: var(--muted-foreground);
    }
    &[aria-pressed="true"] {
      background-color: var(--accent);
      color: var(--accent-foreground);
      &:hover:not(:disabled) {
        background-color: color-mix(in oklch, var(--accent) 85%, var(--foreground));
      }
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
    /* disabled: dimmed, with the not-allowed cursor - no pointer-events: none,
       which would hide the cursor too (a disabled button fires no click) */
    &:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
    & svg {
      pointer-events: none;
      flex-shrink: 0;
      &:not([class*="size-"]) { width: 1rem; height: 1rem; }
    }
    /* -- Variants -------------------------------------------- */
    &[data-variant="outline"] {
      border: 1px solid var(--input);
      background: transparent;
      box-shadow: var(--shadow-xs);
      &:hover:not(:disabled) {
        background-color: var(--accent);
        color: var(--accent-foreground);
      }
      &[aria-pressed="true"] {
        background-color: var(--accent);
        color: var(--accent-foreground);
        &:hover:not(:disabled) {
          background-color: color-mix(in oklch, var(--accent) 85%, var(--foreground));
        }
      }
    }
    /* -- Sizes ----------------------------------------------- */
    &[data-size="xs"]  { height: 1.75rem; padding: 0 0.25rem;  min-width: 1.75rem; font-size: 0.75rem; }
    &[data-size="sm"]  { height: 2rem;  padding: 0 0.375rem; min-width: 2rem; }
    &[data-size="md"]  { height: 2.25rem; padding: 0 0.5rem;   min-width: 2.25rem; }
    &[data-size="lg"]  { height: 2.5rem; padding: 0 0.625rem; min-width: 2.5rem; }
    &[data-size="xl"]  { height: 3rem;  padding: 0 0.75rem;  min-width: 3rem; font-size: 1rem; }
  }
  @media (prefers-reduced-motion: reduce) {
    .toggle { transition: none; }
  }
  @media (prefers-contrast: more) {
    .toggle {
      border: 2px solid transparent;
      &[data-variant="outline"] {
        border-color: var(--foreground);
      }
      &[aria-pressed="true"] {
        border-color: currentColor;
      }
    }
  }
  @media (forced-colors: active) {
    .toggle {
      border: 1px solid ButtonText;
      &[aria-pressed="true"] {
        background: Highlight;
        color: HighlightText;
      }
      &:disabled {
        border-color: GrayText;
        color: GrayText;
      }
    }
  }
}

§JavaScript view file

Single click handler toggles aria-pressed between "true" and "false".

// -- Toggle ---------------------------------------------------
// Toggles aria-pressed on .toggle buttons, plus the named-state API so
// agents/tests can drive the pressed state by name (AGENTS.md "State API").
// Skips toggles inside .toggle-group - those are managed by toggle-group.js.
// The State API runs on a store per button (el.store, AGENTS.md "State
// through stores"); render(state) reproduces the authored markup 1:1.
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const toggleStates = ['default', 'pressed'];
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state - the toggle's states take none. */
export interface ToggleStateConfigs {
  /** Not pressed (aria-pressed="false"). */
  default: {};
  /** Pressed (aria-pressed="true"). */
  pressed: {};
}
/**
 * The markup of a state - the one place a state becomes attributes. setState
 * runs it on the live button, render() on a detached copy of the authored
 * markup. 'default' is the authored aria-pressed value.
 */
function applyMarkup(toggle, stateName, defaultPressed) {
  dfDollar(toggle).attr('aria-pressed', stateName === 'pressed' ? 'true' : defaultPressed);
}
/** UI side of setState (AGENTS.md "State API"): the state's markup. */
function triggerStateChange(toggle, stateName, _config) {
  applyMarkup(toggle, stateName, toggle._defaultPressed ?? 'false');
}
/** Registry-level API; pass the toggle button explicitly. Unknown names throw. */
export const toggleApi = componentState({
  component: 'toggle',
  states: toggleStates,
  apply: (toggle, state) => triggerStateChange(toggle, state.name, state.config),
  // reflect reality: user clicks change aria-pressed without setState()
  read: (toggle, state) => ({ name: dfDollar(toggle).attr('aria-pressed') === 'true' ? 'pressed' : 'default', config: state.config }),
  markup: (toggle, state) => {
    const authored = state.model.attrs.find(([name]) => name === 'aria-pressed');
    applyMarkup(toggle, state.name, authored ? authored[1] : 'false');
  },
});
df$.toggleApi = toggleApi;
df$.toggleStates = toggleStates;
function init() {
  dfDollar('.toggle:not([data-init]):not(.toggle-group .toggle)').each((_i, toggle) => {
    dfDollar(toggle).data('init', '');
    // remember the authored pressed state so setState('default') can restore it
    toggle._defaultPressed = dfDollar(toggle).attr('aria-pressed') || 'false';
    // el.store + el.api: `$('#my-toggle').api.setState('pressed')`
    bindComponent(toggle, toggleApi, { name: toggle._defaultPressed === 'true' ? 'pressed' : 'default', config: {} });
    dfDollar(toggle).on('click', () => {
      dfDollar(toggle).attr('aria-pressed', String(dfDollar(toggle).attr('aria-pressed') !== 'true'));
    });
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub