Theme
On this page (8)
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).

§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.
// 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 } from '../../shared/state-api.js';
const df$ = defussGlobals();
const toggleStates = ['default', 'pressed'];
/**
 * UI side of setState: 'pressed' sets aria-pressed=true, 'default' restores
 * the authored aria-pressed value (snapshotted at init per instance).
 */
function triggerStateChange(toggle, stateName, _config) {
  switch (stateName) {
    case 'default':
      toggle.setAttribute('aria-pressed', toggle._defaultPressed ?? 'false');
      break;
    case 'pressed':
      toggle.setAttribute('aria-pressed', 'true');
      break;
  }
}
/** Registry-level API; pass the toggle button explicitly. Unknown names throw. */
export const toggleApi = {
  setState(toggle, stateName, config = {}) {
    if (!toggleStates.includes(stateName)) {
      throw new Error(`toggle: unknown state "${stateName}" (supported: ${toggleStates.join(', ')})`);
    }
    triggerStateChange(toggle, stateName, config);
    // state lives on the ELEMENT, not the module (many toggles per page)
    toggle.dataset.stateName = stateName;
    toggle._stateConfig = config;
  },
  getState(toggle) {
    // reflect reality: user clicks change aria-pressed without setState()
    const pressed = toggle.getAttribute('aria-pressed') === 'true';
    return {
      name: toggle.dataset.stateName || (pressed ? 'pressed' : 'default'),
      config: toggle._stateConfig ?? {},
    };
  },
};
df$.toggleApi = toggleApi;
df$.toggleStates = toggleStates;
function init() {
  document.querySelectorAll('.toggle:not([data-init]):not(.toggle-group .toggle)').forEach((toggle) => {
  toggle.dataset.init = '';
  // remember the authored pressed state so setState('default') can restore it
  toggle._defaultPressed = toggle.getAttribute('aria-pressed') || 'false';
  // bind-scope the api per instance: `$('#my-toggle').api.setState('pressed')`
  toggle.api = {
    setState: (stateName, config) => toggleApi.setState(toggle, stateName, config),
    getState: () => toggleApi.getState(toggle),
  };
  toggle.addEventListener('click', () => {
    const pressed = toggle.getAttribute('aria-pressed') === 'true';
    toggle.setAttribute('aria-pressed', !pressed);
    // user interaction also moves the named state (keeps getState honest)
    toggle.dataset.stateName = !pressed ? 'pressed' : 'default';
  });
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

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