Skip to content

Toggle

ui

Preview

<ui-toggle aria-label="Toggle italic">
  <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
</ui-toggle>

Installation

webjs ui add toggle

Variants

<ui-toggle variant="default" aria-label="Toggle default">Default</ui-toggle>
<ui-toggle variant="outline" aria-label="Toggle outline">Outline</ui-toggle>

Sizes

<ui-toggle variant="outline" size="default" aria-label="Toggle default">default</ui-toggle>
<ui-toggle variant="outline" size="sm" aria-label="Toggle sm">sm</ui-toggle>
<ui-toggle variant="outline" size="lg" aria-label="Toggle lg">lg</ui-toggle>

API Reference

Parts

Name Description
toggleClass({ variant, size }) Apply to a native <button> for the controlled pattern.
<ui-toggle> Stateful custom element, manages aria-pressed + data-state for you.

Props

Prop Type Default Description
variant "default" | "outline" "default"
size "default" | "sm" | "lg" "default"
pressed boolean (attribute) false On <ui-toggle>, initial pressed state.
disabled boolean (attribute) false

Events

Name Detail Description
ui-pressed-change { pressed: boolean } Fired when the pressed state changes.

Source: components/ui/toggle.ts

/**
 * Toggle: pressable on / off button. Ships both as a Tier-1 class helper
 * (for callers that want to own pressed state on a native `<button>`)
 * and as the Tier-2 `<ui-toggle>` custom element (for callers that want
 * state managed for them).
 *
 * shadcn parity:
 *   Toggle (variant: default | outline)
 *          (size:    default | sm | lg)
 *                                 → toggleClass({ variant, size })  (class helper)
 *                                 → <ui-toggle pressed variant size>  (custom element)
 *
 * Attributes on <ui-toggle>:
 *   `pressed`:  boolean (reflected). Active state.
 *   `variant`:  "default" (default) | "outline".
 *   `size`:     "default" (default) | "sm" | "lg".
 *   `disabled`: boolean (reflected). Disables click + focus.
 *
 * Events:
 *   `ui-pressed-change` on <ui-toggle>: `{ detail: { pressed } }` after a click.
 *
 * Keyboard: native button. Enter / Space activates (via the inner <button>).
 *
 * Design tokens used: --muted, --muted-foreground, --accent, --accent-foreground,
 * --input, --background, --ring, --destructive.
 *
 * @example
 * ```html
 * <!-- Tier-1 class helper, caller owns state. -->
 * <button class=${toggleClass()} data-state="off" aria-pressed="false"
 *         onclick="this.dataset.state = this.dataset.state==='on'?'off':'on'">
 *   <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 12a4 4 0 0 0 0-8H6v8" /><path d="M15 20a4 4 0 0 0 0-8H6v8Z" /></svg>
 * </button>
 *
 * <!-- Tier-2 custom element, state managed. -->
 * <ui-toggle aria-label="Toggle bold">
 *   <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 12a4 4 0 0 0 0-8H6v8" /><path d="M15 20a4 4 0 0 0 0-8H6v8Z" /></svg>
 * </ui-toggle>
 *
 * <!-- Controlled or initial state. -->
 * <ui-toggle variant="outline" size="sm" pressed>B</ui-toggle>
 * ```
 */
import { WebComponent, html, prop } from '@webjsdev/core';
import { cn } from '../lib/utils.ts';

// cursor-pointer + select-none on BASE for both call sites: the
// class-helper applied to a native <button> (where shadcn's upstream
// also omits it; see the same convention the button fix applies) and
// the <ui-toggle> custom element. select-none prevents drag-selecting
// icon/label glyphs that aren't meant to be selectable. disabled:
// pointer-events-none below already suppresses cursor for disabled buttons.
const BASE =
  "inline-flex cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap select-none transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";

const VARIANTS = {
  default: 'bg-transparent',
  outline:
    'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
} as const;

const SIZES = {
  default: 'h-9 min-w-9 px-2',
  sm: 'h-8 min-w-8 px-1.5',
  lg: 'h-10 min-w-10 px-2.5',
} as const;

export type ToggleVariant = keyof typeof VARIANTS;
export type ToggleSize = keyof typeof SIZES;

export function toggleClass(opts: { variant?: ToggleVariant; size?: ToggleSize } = {}): string {
  return cn(BASE, VARIANTS[opts.variant ?? 'default'], SIZES[opts.size ?? 'default']);
}

// --------------------------------------------------------------------------
// <ui-toggle> wraps a native <button> and tracks pressed state. Native
// <button> handles Enter/Space → click + focus + disabled semantics for
// free; we only own the pressed-state toggle on click. Authored children
// project through the default slot inside the inner button.
// --------------------------------------------------------------------------

export class UiToggle extends WebComponent({
  pressed: prop(Boolean, { reflect: true }),
  variant: prop<ToggleVariant>(String, { reflect: true }),
  size: prop<ToggleSize>(String, { reflect: true }),
  disabled: prop(Boolean, { reflect: true }),
}) {
  constructor() {
    super();
    this.pressed = false;
    this.variant = 'default';
    this.size = 'default';
    this.disabled = false;
  }

  render() {
    return html`<button
      type="button"
      data-slot="toggle"
      class=${toggleClass({ variant: this.variant, size: this.size })}
      aria-pressed=${String(this.pressed)}
      data-state=${this.pressed ? 'on' : 'off'}
      ?disabled=${this.disabled}
      @click=${this._onClick}
    ><slot></slot></button>`;
  }

  _onClick = (): void => {
    this.pressed = !this.pressed;
    this.dispatchEvent(
      new CustomEvent('ui-pressed-change', { detail: { pressed: this.pressed }, bubbles: true }),
    );
  };
}
UiToggle.register('ui-toggle');