Toggle Switch Group

A settings-style panel of animated toggle switches, grouped under labeled sections. Each row has a label and optional description. The thumb slides smoothly on toggle. Fully accessible: role='switch', aria-checked, keyboard-toggled with Space and Enter.

Forms & InputsInspired by Stripe
Live PreviewInteractive
Stripe · Settings
Notifications
EmailReceive updates via email
PushBrowser and mobile push alerts
SMSText message alerts for critical events
Integrations
SlackPost activity to a Slack channel
GitHubSync issues with GitHub repositories

Click or Space / Enter to toggle · Smooth thumb animation

ToggleSwitchGroup.tsx
// Dependencies: react ^18
import React, { useState } from 'react';

function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

type ToggleItem = {
  id: string;
  label: string;
  description?: string;
  enabled: boolean;
};

type ToggleGroup = {
  id: string;
  label: string;
  items: ToggleItem[];
};

type Props = {
  groups: ToggleGroup[];
  onChange?: (groupId: string, itemId: string, enabled: boolean) => void;
};

function Switch({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) {
  return (
    <button
      role="switch"
      aria-checked={enabled}
      onClick={onToggle}
      onKeyDown={(e) => {
        if (e.key === 'Enter') { e.preventDefault(); onToggle(); }
      }}
      className={cn(
        'relative flex-shrink-0 w-9 h-5 rounded-full transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-[#0a0a0a]',
        enabled ? 'bg-indigo-500' : 'bg-zinc-700'
      )}
    >
      <span
        className={cn(
          'pointer-events-none absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform duration-200',
          enabled ? 'translate-x-4' : 'translate-x-0'
        )}
      />
    </button>
  );
}

export function ToggleSwitchGroup({ groups, onChange }: Props) {
  const [state, setState] = useState<Record<string, Record<string, boolean>>>(
    () => Object.fromEntries(
      groups.map((g) => [g.id, Object.fromEntries(g.items.map((i) => [i.id, i.enabled]))])
    )
  );

  const toggle = (groupId: string, itemId: string) => {
    const next = !state[groupId][itemId];
    setState((prev) => ({
      ...prev,
      [groupId]: { ...prev[groupId], [itemId]: next },
    }));
    onChange?.(groupId, itemId, next);
  };

  return (
    <div className="w-full flex flex-col gap-4">
      {groups.map((group) => (
        <div
          key={group.id}
          className="flex flex-col bg-[#0a0a0a] border border-[#1a1a1a] rounded-xl overflow-hidden"
        >
          <div className="px-4 py-2.5 border-b border-[#1a1a1a]">
            <span className="text-xs font-semibold text-zinc-500 uppercase tracking-wide">
              {group.label}
            </span>
          </div>
          <div className="divide-y divide-[#1a1a1a]">
            {group.items.map((item) => (
              <div key={item.id} className="flex items-center justify-between px-4 py-3">
                <div className="flex flex-col gap-0.5 min-w-0 mr-4">
                  <span className="text-sm font-medium text-white">{item.label}</span>
                  {item.description && (
                    <span className="text-xs text-zinc-500 leading-snug">{item.description}</span>
                  )}
                </div>
                <Switch
                  enabled={state[group.id][item.id]}
                  onToggle={() => toggle(group.id, item.id)}
                />
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

// Usage:
// const GROUPS = [
//   {
//     id: 'notifications',
//     label: 'Notifications',
//     items: [
//       { id: 'email', label: 'Email', description: 'Receive updates via email', enabled: true },
//       { id: 'push', label: 'Push', description: 'Browser push alerts', enabled: false },
//     ],
//   },
// ];
// <ToggleSwitchGroup groups={GROUPS} onChange={(gId, iId, val) => console.log(gId, iId, val)} />

Unlock to copy

Free access to all patterns