Lunia DS

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.

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-picker

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
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.
valuestring-Id of the chosen option.
effortstringundefinedThinking effort for models that reason. The Thinking control shows when this and the effortChange listener are set.
effortsstring[]["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.
collisionPaddingnumber16Space kept from the viewport edges.
anchorHTMLElement | nullnullOpen clear of this element (for example the composer) and line up with its edge.
disabledbooleanfalseTurns the picker off.
labelstring"Model"What the choice is, for screen readers, for example "Permission".
EventDescription
valueChangeAnother option was chosen. Payload: its id.
effortChangeThe effort level changed. Payload: the new level.
lockedSelectA locked option was chosen, for example to open pricing. Payload: the option.
SlotDescription
iconIcon 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
MP1Opensurfacewidth, height, positionpill size → panel sizeduration-xl (380)ease-settleShows at once
MP2Opensurfacecorner radiusradius-full → radius-3xlduration-xl (380)ease-settleShows at once
MP3Opensurfaceshadownone → shadow-lgduration-md (260)ease-settleShows at once
MP4Openpill labelopacity1 → 0duration-2xs (120)ease-exitHides at once
MP5Openlistopacity0 → 1duration-md (260)ease-exitdelay 110msShows at once
MP6Openlisttranslate ydistance-nudge → 0duration-xl (380)ease-settleStays still
MP7Closelistopacity1 → 0duration-2xs (120)ease-exitHides at once
MP8Closelisttranslate y0 → distance-nudgeduration-sm (200)ease-exitStays still
MP9Closepill labelopacity0 → 1duration-sm (200)ease-exitdelay 140msShows at once
MP10Open or closechevronrotation0deg → 180degduration-lg (320)ease-settleSwaps at once
MP11Pointer or keyboard moves to a rowrow highlighttranslate ylast row → new rowduration-sm (200)ease-settleMoves at once
MP12Effort changesselected segmenttranslate x, widthold option → new optionduration-md (260)ease-settleMoves at once
MP13Model or effort changespill name, effort wordtext morphLumen 2 Pro → Orbit 5duration-3xl (460)ease-outSwaps at once
MP14Model changespill iconopacity, blur0, blur-swap → 1, 0duration-sm (200)ease-outSwaps at once
MP17Model changespill iconscalescale-swap → 1duration-xl (380)ease-outSwaps at once
MP15Effort showneffort wordwidth (grid columns)0fr → 1frduration-xl (380)ease-outShows at once
MP18Effort showneffort wordopacity0 → 1duration-md (260)ease-outShows at once
MP19Effort hiddeneffort wordopacity1 → 0duration-xs (160)ease-exitHides at once
MP16A model that reasons is chosenthinking controlopacity0 → 1duration-sm (200)ease-exitShows at once
MP20A model that reasons is chosensurfaceheightold height → height with the thinking controlduration-xl (380)ease-settleResizes 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.