Lunia DS

Navigation and workspace

Mode switcher

A segmented switch for modes such as Fast, Thinking and Research, where a pill is thrown to the chosen mode, the mode's one-line description morphs in place, and locked modes stay visible with a lock.

Loading preview

Three modes with icons: Fast ("Quick answers for simple questions"), Thinking ("Slower answers that reason step by step") and Research (locked, "Deep answers that search many sources"). The description line is on. Replay chooses Fast, then Thinking, then clicks Research, which shows a small "Research is not on your plan" note instead of switching, then goes back to Fast.

Installation

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

$ npx @ganisigit/lunia-ds add mode-switcher

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { type Mode, ModeSwitcher } from './mode-switcher';

const icon = (path: React.ReactNode) => (
  <svg
    aria-hidden="true"
    viewBox="0 0 14 14"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
  >
    {path}
  </svg>
);

const MODES: Mode[] = [
  {
    value: 'fast',
    label: 'Fast',
    description: 'Quick answers for simple questions',
    icon: icon(<path d="M7.75 1.5 2.5 8h4l-.5 4.5L11.5 6h-4z" />),
  },
  {
    value: 'thinking',
    label: 'Thinking',
    description: 'Slower answers that reason step by step',
    icon: icon(
      <>
        <path d="M5.5 2.5 6.6 5.4 9.5 6.5 6.6 7.6 5.5 10.5 4.4 7.6 1.5 6.5 4.4 5.4z" />
        <path d="M10.75 8.5v3M9.25 10h3M10.5 1.5v2M9.5 2.5h2" />
      </>,
    ),
  },
  {
    value: 'research',
    label: 'Research',
    description: 'Deep answers that search many sources',
    locked: true,
    icon: icon(
      <>
        <circle cx="7" cy="7" r="5.5" />
        <path d="M1.5 7h11M7 1.5c1.6 1.6 2.4 3.4 2.4 5.5S8.6 10.9 7 12.5C5.4 10.9 4.6 9.1 4.6 7S5.4 3.1 7 1.5z" />
      </>,
    ),
  },
];

const SCRIPT: [at: number, step: 'fast' | 'thinking' | 'research'][] = [
  [500, 'fast'],
  [1900, 'thinking'],
  [3300, 'research'],
  [5600, 'fast'],
];

export default function ModeSwitcherDemo({ replay = 0 }: { replay?: number }) {
  const [value, setValue] = React.useState('thinking');
  const [note, setNote] = React.useState(false);
  const timers = React.useRef<number[]>([]);
  const noteTimer = React.useRef(0);

  const showNote = React.useCallback(() => {
    setNote(true);
    window.clearTimeout(noteTimer.current);
    noteTimer.current = window.setTimeout(() => setNote(false), 2000);
  }, []);

  // Replay chooses Fast, then Thinking, then presses Research (a locked mode, so it only shows a note), then Fast.
  React.useEffect(() => {
    if (replay === 0) return;
    for (const id of timers.current) window.clearTimeout(id);
    timers.current = SCRIPT.map(([at, step]) =>
      window.setTimeout(() => (step === 'research' ? showNote() : setValue(step)), at),
    );
    return () => {
      for (const id of timers.current) window.clearTimeout(id);
      window.clearTimeout(noteTimer.current);
    };
  }, [replay, showNote]);

  return (
    <div className="flex w-full max-w-[420px] flex-col items-start gap-3 rounded-xl border border-secondary bg-primary p-6">
      <ModeSwitcher
        modes={MODES}
        value={value}
        showDescription
        onValueChange={setValue}
        onLockedSelect={showNote}
      />
      <p role="status" className="min-h-5 text-xs text-tertiary">
        {note ? 'Research is not on your plan.' : ''}
      </p>
    </div>
  );
}

API

Parts: root, track (radio group), pill (under the chosen mode), mode button, mode icon (optional), mode label, lock icon (locked modes), description line (text morph, optional).

PropTypeDefaultDescription
modesMode[]-Modes in order: { value, label, description?, icon?, locked? }. A locked mode is shown with a lock and cannot be chosen. Required.
valuestring-Value of the chosen mode when controlled.
defaultValuestringfirst mode that is not lockedValue of the mode chosen at first when not controlled.
showDescriptionbooleanfalseShows the chosen mode's description in one line under the switch.
labelstring"Mode"Accessible name of the radio group.
EventDescription
valueChangeFires with the value of the newly chosen mode.
lockedSelectFires with the mode when someone clicks a locked mode, for example to open the plans page. The chosen mode does not change.
SlotDescription
iconPer mode: an optional 14px icon before the label.

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
MS1a mode is chosenpilltransform translateXold mode left → new mode leftduration-3xl (460)ease-throwMoves at once
MS2a mode is chosenpillwidthold mode width → new mode widthduration-xl (380)ease-outResizes at once
MS3hover a mode, or a mode is chosenmode label + iconcolortext-tertiary → text-primaryduration-xs (160)ease-outSwaps at once
MS4a mode is chosen (Description On)description linetext morph (shared letters slide, new letters rise; width does not animate)old description → new descriptionduration-3xl (460)ease-outSwaps at once

Accessibility

  • A radio group with an accessible name (label prop); each mode is a radio with aria-checked.
  • Roving tab index: only the chosen mode is in the tab order; arrow keys choose the next or previous mode that is not locked, and wrap.
  • Locked modes have aria-disabled and are skipped by the arrow keys, but a click still fires lockedSelect; their tooltip says the mode is not on the current plan.
  • The lock icon and the mode icon are aria-hidden; the label carries the name.
  • The pill is aria-hidden.
  • Mode buttons are 28px tall, above the 24px minimum target; visible focus ring on each.
  • Reduced motion: the pill moves and resizes at once, and the description swaps at once.