AI input
Model picker
A pill that grows into a list of models or permission levels and folds back into the pill, so the choice always comes from the same place.
Live preview
Loading preview
A model pill in a toolbar with four models, two of which reason and show the Thinking control, and a second picker for permission levels. Replay opens the model list, moves the highlight and closes it.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add model-pickerUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { type ModelOption, ModelPicker } from './model-picker';
const MODELS: ModelOption[] = [
{
id: 'pro',
name: 'Lumen 2 Pro',
description: 'Best for hard, long tasks',
speed: 1,
intelligence: 3,
reasoning: true,
},
{
id: 'orbit',
name: 'Orbit 5',
description: 'Fast and smart for most work',
speed: 2,
intelligence: 2,
reasoning: true,
},
{ id: 'lumen', name: 'Lumen 2', description: 'Strong at code and reasoning', speed: 2, intelligence: 3 },
{
id: 'atlas',
name: 'Atlas Fast',
description: 'Quick answers to simple questions',
speed: 3,
intelligence: 1,
},
];
const PERMISSIONS: ModelOption[] = [
{ id: 'auto', name: 'Auto', description: 'Asks only before risky steps' },
{ id: 'ask', name: 'Ask first', description: 'Asks you before each change' },
{ id: 'plan', name: 'Plan only', description: 'Makes a plan and changes nothing' },
{ id: 'full', name: 'Full access', description: 'Runs every step without asking' },
];
/** Own 16px marks for the demo options. */
const MARKS: Record<string, string> = {
pro: 'M6.667 2 7.6 5.067 10.667 6 7.6 6.933 6.667 10 5.733 6.933 2.667 6l3.066-.933zM11.667 9.333l.5 1.5 1.5.5-1.5.5-.5 1.5-.5-1.5-1.5-.5 1.5-.5z',
orbit: 'M8 1.667 13.5 4.833v6.334L8 14.333 2.5 11.167V4.833z',
lumen: 'M8 2.333 9.25 6.75 13.667 8 9.25 9.25 8 13.667 6.75 9.25 2.333 8 6.75 6.75z',
atlas: 'M8.667 1.333 2.667 9.333H8l-.667 5.334 6-8H8z',
auto: 'M2.667 11.333a5.333 5.333 0 1 1 10.666 0M8 11.333l2.667-3.333',
ask: 'M5.333 9.333V3.667a1 1 0 0 1 2 0v3.666M7.333 7V2.667a1 1 0 0 1 2 0V7M9.333 7.333V4a1 1 0 0 1 2 0v4.667a4.667 4.667 0 0 1-4.666 4.666c-1.6 0-2.734-.766-3.534-2.066L2.4 9.267a1 1 0 0 1 1.667-1.1l1.266 1.5',
plan: 'M5.333 4h8M5.333 8h8M5.333 12h8M2.667 4h.007M2.667 8h.007M2.667 12h.007',
full: 'M8 14.667s5.333-2.667 5.333-6.667V3.333L8 1.333 2.667 3.333V8c0 4 5.333 6.667 5.333 6.667',
};
function Mark({ option }: { option: ModelOption }) {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={MARKS[option.id]} />
</svg>
);
}
// Replay: open the model list, move the highlight down two rows and back, then close.
const SCRIPT: [number, string][] = [
[0, 'open'],
[700, 'ArrowDown'],
[1050, 'ArrowDown'],
[1400, 'ArrowUp'],
[2000, 'Escape'],
];
export default function ModelPickerDemo({ replay = 0 }: { replay?: number }) {
const [model, setModel] = React.useState('pro');
const [effort, setEffort] = React.useState('High');
const [permission, setPermission] = React.useState('ask');
const rootRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (replay === 0) return;
const root = rootRef.current;
const timers = SCRIPT.map(([at, step]) =>
window.setTimeout(() => {
const picker = root?.querySelector('[data-slot="model-picker"]');
if (step === 'open') {
const trigger = picker?.querySelector<HTMLButtonElement>('button[aria-haspopup]');
if (trigger?.getAttribute('aria-expanded') !== 'true') trigger?.click();
return;
}
picker
?.querySelector('[role="listbox"]')
?.dispatchEvent(new KeyboardEvent('keydown', { key: step, bubbles: true }));
}, at),
);
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<div ref={rootRef} className="flex min-h-96 w-full flex-col justify-end">
<div className="mx-auto flex w-full max-w-md items-center gap-1 rounded-2xl border border-secondary bg-primary_alt p-1.5 shadow-xs">
<ModelPicker
models={MODELS}
value={model}
effort={effort}
onValueChange={setModel}
onEffortChange={setEffort}
renderIcon={(option) => <Mark option={option} />}
/>
<ModelPicker
models={PERMISSIONS}
value={permission}
label="Permission"
onValueChange={setPermission}
renderIcon={(option) => <Mark option={option} />}
/>
</div>
</div>
);
}
API
Parts: trigger pill, model icon, model name (text morph), effort word (text morph), chevron, surface (grows from the pill), list (listbox), option row, row highlight, speed and smarts meters, check, thinking control (segmented).
| Prop | Type | Default | Description |
|---|---|---|---|
| models | { id: string; name: string; description?: string; speed?: 1 | 2 | 3; intelligence?: 1 | 2 | 3; reasoning?: boolean; locked?: boolean }[] | - | Options in the list. An icon can be set per option through the icon slot. |
| value | string | - | Id of the chosen option. |
| effort | string | undefined | Thinking effort for models that reason. The Thinking control shows when this and the effortChange listener are set. |
| efforts | string[] | ["Low", "Medium", "High"] | Effort levels in the Thinking control. |
| side | "top" | "bottom" | "top" | Grow upward or downward from the pill. |
| align | "start" | "end" | "start" | Grow from the left or the right edge of the pill. |
| collisionPadding | number | 16 | Space kept from the viewport edges. |
| anchor | HTMLElement | null | null | Open clear of this element (for example the composer) and line up with its edge. |
| disabled | boolean | false | Turns the picker off. |
| label | string | "Model" | What the choice is, for screen readers, for example "Permission". |
| Event | Description |
|---|---|
| valueChange | Another option was chosen. Payload: its id. |
| effortChange | The effort level changed. Payload: the new level. |
| lockedSelect | A locked option was chosen, for example to open pricing. Payload: the option. |
| Slot | Description |
|---|---|
| icon | Icon per option, also shown in the pill. |
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 |
|---|---|---|---|---|---|---|
| MP1 | Open | surface | width, height, position | pill size → panel size | duration-xl (380)ease-settle | Shows at once |
| MP2 | Open | surface | corner radius | radius-full → radius-3xl | duration-xl (380)ease-settle | Shows at once |
| MP3 | Open | surface | shadow | none → shadow-lg | duration-md (260)ease-settle | Shows at once |
| MP4 | Open | pill label | opacity | 1 → 0 | duration-2xs (120)ease-exit | Hides at once |
| MP5 | Open | list | opacity | 0 → 1 | duration-md (260)ease-exitdelay 110ms | Shows at once |
| MP6 | Open | list | translate y | distance-nudge → 0 | duration-xl (380)ease-settle | Stays still |
| MP7 | Close | list | opacity | 1 → 0 | duration-2xs (120)ease-exit | Hides at once |
| MP8 | Close | list | translate y | 0 → distance-nudge | duration-sm (200)ease-exit | Stays still |
| MP9 | Close | pill label | opacity | 0 → 1 | duration-sm (200)ease-exitdelay 140ms | Shows at once |
| MP10 | Open or close | chevron | rotation | 0deg → 180deg | duration-lg (320)ease-settle | Swaps at once |
| MP11 | Pointer or keyboard moves to a row | row highlight | translate y | last row → new row | duration-sm (200)ease-settle | Moves at once |
| MP12 | Effort changes | selected segment | translate x, width | old option → new option | duration-md (260)ease-settle | Moves at once |
| MP13 | Model or effort changes | pill name, effort word | text morph | Lumen 2 Pro → Orbit 5 | duration-3xl (460)ease-out | Swaps at once |
| MP14 | Model changes | pill icon | opacity, blur | 0, blur-swap → 1, 0 | duration-sm (200)ease-out | Swaps at once |
| MP17 | Model changes | pill icon | scale | scale-swap → 1 | duration-xl (380)ease-out | Swaps at once |
| MP15 | Effort shown | effort word | width (grid columns) | 0fr → 1fr | duration-xl (380)ease-out | Shows at once |
| MP18 | Effort shown | effort word | opacity | 0 → 1 | duration-md (260)ease-out | Shows at once |
| MP19 | Effort hidden | effort word | opacity | 1 → 0 | duration-xs (160)ease-exit | Hides at once |
| MP16 | A model that reasons is chosen | thinking control | opacity | 0 → 1 | duration-sm (200)ease-exit | Shows at once |
| MP20 | A model that reasons is chosen | surface | height | old height → height with the thinking control | duration-xl (380)ease-settle | Resizes at once |
Accessibility
- The pill is a button with aria-haspopup="listbox", aria-expanded and aria-controls; its accessible name includes the chosen option and effort, for example "Model: Lumen 2 Pro, High thinking".
- The list is role="listbox" with aria-activedescendant; each row is role="option" with aria-selected, and locked rows have aria-disabled.
- Up and Down arrow keys move through the rows, Enter chooses, Escape closes and returns focus to the pill.
- The Thinking control is a role="radiogroup"; Left and Right arrow keys change the effort.
- Meters have a text title such as "Speed: 2 of 3"; their dots are aria-hidden.