Skip to content
UI Kit
Accent color

Switch for React

On and off for settings that apply right away, with role="switch", as a list or as rows.

One of 9 components in Forms and inputs

2 variants

On and off, right away

A switch turns a setting on or off with immediate effect, like a light switch. This one is a real button with role switch and aria-checked, so screen readers announce it as on or off, Space toggles it, and the label is part of the click area.

Settings list stacks switches with a title and a sentence each, the layout of a notifications page. Rows with icons adds an icon per row and fits app settings such as Dark mode, Sounds and Auto save. Both show the state with position and color, and the thumb moves calmly, or not at all with reduced motion.

Use a switch only when the change applies at once. In a form that is saved with a button, use a checkbox instead. Label the setting, not the state, Email notifications rather than Turn on email, the switch itself shows whether it is on.

Variants

Settings list

Label and description left, the switch right, as people know it from their phone. The knob springs across and draws a small check.

Rows with icons

Each setting on its own surface, with an icon that names the topic.

Details

What Switch brings along, read from the registry and the source.

Variants
2
Version
1.0.0 · since 1.0.0
File
components/ui/switch.tsx
Brings along
utilskiticon
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The SwitchProps interface from components/ui/switch.tsx, with the comments from the code. Also takes every prop of Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'value'>.

  • checkedoptional
    boolean
  • defaultCheckedoptional
    boolean
  • onCheckedChangeoptional
    (checked: boolean) => void
  • labeloptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • sizeoptional
    'sm' | 'md'
  • positionoptional
    'start' | 'end'

    Where the switch sits next to the label. end is the settings-list look.

  • variantoptional
    'default' | 'row'

    row: a bordered settings row, optionally with an icon

  • iconoptional
    IconName
  • nameoptional
    string

    Form name, a hidden input carries "on" when checked

Accessibility

Read from the source of the component.

  • Sets aria-checked, aria-describedby and aria-labelledby, so screen readers announce purpose and state.
  • Uses the role switch.
  • Every field has a label, visible or for screen readers.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 138 lines
components/ui/switch.tsx
'use client';

import { useId, useState } from 'react';
import { cn } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';

export interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange' | 'value'> {
  checked?: boolean;
  defaultChecked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
  label?: React.ReactNode;
  description?: React.ReactNode;
  size?: 'sm' | 'md';
  /** Where the switch sits next to the label. end is the settings-list look. */
  position?: 'start' | 'end';
  /** row: a bordered settings row, optionally with an icon */
  variant?: 'default' | 'row';
  icon?: IconName;
  /** Form name, a hidden input carries "on" when checked */
  name?: string;
  className?: string;
}

const SIZE = {
  sm: { track: 'h-5 w-9', thumb: 'size-4', on: 'translate-x-4', mark: 8 },
  md: { track: 'h-6 w-11', thumb: 'size-5', on: 'translate-x-5', mark: 10 },
};

/**
 * An on/off setting that applies right away. A button with role="switch" and aria-checked.
 *   <Switch label="Email digest" description="A summary every Monday" defaultChecked />
 */
export function Switch({ checked, defaultChecked, onCheckedChange, label, description, size = 'md', position = 'end', variant = 'default', icon, name, className, id, disabled, onClick, ...rest }: SwitchProps) {
  const auto = useId();
  const sid = id ?? auto;
  const [inner, setInner] = useState(defaultChecked ?? false);
  const on = checked ?? inner;
  const s = SIZE[size];

  const control = (
    <button
      type="button"
      role="switch"
      id={sid}
      aria-checked={on}
      aria-labelledby={label ? `${sid}-label` : undefined}
      aria-describedby={description ? `${sid}-desc` : undefined}
      disabled={disabled}
      {...rest}
      onClick={(e) => {
        onClick?.(e);
        setInner(!on);
        onCheckedChange?.(!on);
      }}
      className={cn(
        'group/switch relative inline-flex shrink-0 cursor-pointer items-center rounded-full p-0.5 outline-none transition-[background-color,box-shadow] duration-200 ease-[var(--ease-kit)]',
        'bg-[color-mix(in_oklab,var(--foreground)_14%,var(--background))] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:bg-[color-mix(in_oklab,var(--foreground)_20%,var(--background))]',
        'aria-checked:bg-primary aria-checked:hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]',
        'focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_25%,transparent)]',
        'disabled:cursor-not-allowed disabled:opacity-50',
        // 44 px hit area
        'after:absolute after:-inset-x-1 after:-inset-y-3 after:content-[""]',
        s.track,
        !label && className,
      )}
    >
      {/* The knob travels on a spring: it overshoots a hair and settles. Held down it stretches. A small check draws itself on it. */}
      <span
        aria-hidden
        className={cn(
          'pointer-events-none grid place-items-center rounded-full shadow-[0_1px_3px_rgb(0_0_0/0.2),0_0_0_0.5px_rgb(0_0_0/0.04)] transition-[translate,scale,background-color] duration-[420ms] ease-[cubic-bezier(0.34,1.45,0.64,1)] group-active/switch:scale-x-[1.18] group-active/switch:duration-150 motion-reduce:transition-none',
          s.thumb,
          on ? cn(s.on, 'origin-right bg-primary-foreground') : 'origin-left bg-background dark:bg-[color-mix(in_oklab,var(--foreground)_88%,var(--background))]',
        )}
      >
        <svg viewBox="0 0 24 24" width={s.mark} height={s.mark} fill="none" className="text-primary">
          <path
            d="m5 12.5 4.5 4.5L19 7.5"
            pathLength={1}
            strokeDasharray="1 1"
            stroke="currentColor"
            strokeWidth="3.4"
            strokeLinecap="round"
            strokeLinejoin="round"
            className={cn('transition-[stroke-dashoffset,opacity] ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'opacity-100 delay-150 duration-300 [stroke-dashoffset:0]' : 'opacity-0 duration-150 [stroke-dashoffset:1]')}
          />
        </svg>
      </span>
    </button>
  );

  const hidden = name ? <input type="hidden" name={name} value={on ? 'on' : ''} /> : null;
  if (!label) return (
    <>
      {control}
      {hidden}
    </>
  );

  const text = (
    <span className="min-w-0 flex-1">
      <label id={`${sid}-label`} htmlFor={sid} className={cn('block cursor-pointer text-sm font-medium leading-5 text-foreground', disabled && 'cursor-not-allowed')}>
        {label}
      </label>
      {description && (
        <span id={`${sid}-desc`} className="mt-0.5 block text-[13px] leading-5 text-muted-foreground">
          {description}
        </span>
      )}
    </span>
  );

  return (
    <div
      className={cn(
        'flex items-start gap-3.5',
        variant === 'row' && 'items-center rounded-xl border border-border bg-card px-4 py-3.5 shadow-[var(--shadow-sm)]',
        disabled && 'opacity-60',
        className,
      )}
    >
      {variant === 'row' && icon && (
        <span
          className={cn(
            'grid size-9 shrink-0 place-items-center rounded-lg border bg-surface transition-[color,border-color,background-color] duration-300',
            on ? 'border-[color-mix(in_oklab,var(--primary)_35%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_8%,var(--surface))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'border-border text-muted-foreground',
          )}
        >
          <Icon name={icon} size={18} />
        </span>
      )}
      {position === 'start' && <span className={cn('flex items-center', size === 'md' ? 'h-6 -mt-0.5' : 'h-5')}>{control}</span>}
      {text}
      {position === 'end' && <span className={cn('flex items-center', variant === 'row' ? '' : size === 'md' ? 'h-6 -mt-0.5' : 'h-5')}>{control}</span>}
      {hidden}
    </div>
  );
}

Frequent questions

How do I install Switch?

With aiw add @ai-website/switch. The command puts components/ui/switch.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.

Which variants does Switch have?

2 variants, Settings list and Rows with icons.

Can an AI assistant build with Switch?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Switch with aiw_components and install it with aiw_add @ai-website/switch. Name the variant in your prompt, for example variant="row", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard