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.
Live preview
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-switcherUsage
The demo above, as code.
'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).
| Prop | Type | Default | Description |
|---|---|---|---|
| modes | Mode[] | - | Modes in order: { value, label, description?, icon?, locked? }. A locked mode is shown with a lock and cannot be chosen. Required. |
| value | string | - | Value of the chosen mode when controlled. |
| defaultValue | string | first mode that is not locked | Value of the mode chosen at first when not controlled. |
| showDescription | boolean | false | Shows the chosen mode's description in one line under the switch. |
| label | string | "Mode" | Accessible name of the radio group. |
| Event | Description |
|---|---|
| valueChange | Fires with the value of the newly chosen mode. |
| lockedSelect | Fires with the mode when someone clicks a locked mode, for example to open the plans page. The chosen mode does not change. |
| Slot | Description |
|---|---|
| icon | Per 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.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| MS1 | a mode is chosen | pill | transform translateX | old mode left → new mode left | duration-3xl (460)ease-throw | Moves at once |
| MS2 | a mode is chosen | pill | width | old mode width → new mode width | duration-xl (380)ease-out | Resizes at once |
| MS3 | hover a mode, or a mode is chosen | mode label + icon | color | text-tertiary → text-primary | duration-xs (160)ease-out | Swaps at once |
| MS4 | a mode is chosen (Description On) | description line | text morph (shared letters slide, new letters rise; width does not animate) | old description → new description | duration-3xl (460)ease-out | Swaps 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.