Lunia DS

Base components

Toggle

An on and off switch with an optional label and supporting text, where the thumb is thrown to the other side with a small overshoot and the track eases into the brand color.

Loading preview

A settings list with "Email notifications", "Push notifications" (with supporting text) and a disabled "SMS notifications", in Default and Slim, both sizes. Replay turns each switch on and off in turn.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add toggle

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { Toggle } from './toggle';

const STEP_MS = 450;
const INITIAL = [true, false, false, true, false, false, false, true];

export default function ToggleDemo({ replay = 0 }: { replay?: number }) {
  const [on, setOn] = React.useState(INITIAL);
  const set = (i: number) => (value: boolean) => setOn((list) => list.map((v, j) => (j === i ? value : v)));

  React.useEffect(() => {
    if (replay === 0) return;
    setOn(INITIAL);
    // Every switch that is not disabled turns over in turn, then back again.
    const order = [0, 1, 3, 4, 6, 7];
    const timers = [...order, ...order].map((i, step) =>
      window.setTimeout(() => setOn((list) => list.map((v, j) => (j === i ? !v : v))), (step + 1) * STEP_MS),
    );
    return () => {
      for (const t of timers) window.clearTimeout(t);
    };
  }, [replay]);

  return (
    <div className="flex flex-col items-center gap-10">
      <div className="grid gap-x-12 gap-y-8 sm:grid-cols-2">
        <div className="flex flex-col gap-4">
          <Toggle label="Email notifications" checked={on[0]} onCheckedChange={set(0)} />
          <Toggle
            label="Push notifications"
            supportingText="Get a message on your phone when someone mentions you."
            checked={on[1]}
            onCheckedChange={set(1)}
          />
          <Toggle label="SMS notifications" checked={on[2]} disabled />
        </div>
        <div className="flex flex-col gap-4">
          <Toggle
            type="slim"
            size="md"
            label="Email notifications"
            checked={on[3]}
            onCheckedChange={set(3)}
          />
          <Toggle
            type="slim"
            size="md"
            label="Push notifications"
            supportingText="Get a message on your phone when someone mentions you."
            checked={on[4]}
            onCheckedChange={set(4)}
          />
          <Toggle type="slim" size="md" label="SMS notifications" checked={on[5]} disabled />
        </div>
      </div>
      <div className="flex items-center gap-6">
        <Toggle size="md" aria-label="Dark mode" checked={on[6]} onCheckedChange={set(6)} />
        <Toggle type="slim" aria-label="Sounds" checked={on[7]} onCheckedChange={set(7)} />
      </div>
    </div>
  );
}

API

Parts: root (label element, the whole row is the tap area), track, thumb, text: label and supporting text.

PropTypeDefaultDescription
checkedbooleanundefinedControlled on state.
defaultCheckedbooleanfalseOn state when uncontrolled.
type"default" | "slim""default"Default has a thumb inside the track; slim has a thin track and a full-height thumb with a border.
size"sm" | "md""sm"Default: 36 x 20 or 44 x 24px. Slim: 32 x 16 or 40 x 20px.
labelstringundefinedLabel text. Without it, pass aria-label.
supportingTextstringundefinedSecond line under the label.
disabledbooleanfalseFades the switch to 50%; the text stays at full opacity.
requiredbooleanfalseNative required for form validation.
namestringundefinedForm field name.
valuestring"on"Value sent with the form when on.
EventDescription
checkedChangeThe switch turned on or off. Payload: the new state.
SlotDescription
defaultLabel content, replaces the label prop.
supportingTextSupporting content, replaces the supportingText prop.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
TGL1checked -> true / falsethumbtranslate x0 (off side) → track width - thumb width - 2 x inset (on side)duration-xl (380)ease-throwMoves at once
TGL2checked -> true / falsetrackbackground-colorbg-control-track → bg-brand-solidduration-lg (320)ease-outSwaps at once
TGL3checked -> true / false (slim)thumb borderborder-colortoggle-border → toggle-slim-border_pressedduration-lg (320)ease-outSwaps at once
TGL4hover the switch or its texttrack (slim: thumb border)background-color, border-colorbg-control-track or bg-brand-solid → bg-control-track_hover or bg-brand-solid_hover (slim pressed: toggle-slim-border_pressed-hover)duration-sm (200)ease-outSwaps at once
TGL5keyboard focusfocus layer behind the thumbbox-shadownone → focus ring (2px bg-primary gap, 4px focus-ring)duration-2xs (120)ease-outShows at once

Accessibility

  • Built on a switch primitive: role switch with aria-checked; Space and Enter toggle, and a hidden native input takes part in forms.
  • The label and supporting text are linked with aria-labelledby and aria-describedby; clicking the text toggles the switch.
  • A switch acts at once: use it for settings that take effect right away, and a checkbox for choices that are sent with a form button.
  • Default sm is 20px tall and slim is 16 or 20px: without text, give the switch a 24 x 24px tap area or 4px of space around it (WCAG 2.5.8). With text, the whole row is the tap area.
  • The on state is shown by the thumb position and the track color, not by color alone.
  • Reduced motion: the thumb jumps to its side and the track color swaps at once.