CLI

? Which framework? ↑↓
No dependencies
Code format
React + Tailwind
import { useId, type CSSProperties } from 'react';

const OPTIONS = [
  { value: 'react', label: 'React', note: 'hooks' },
  { value: 'vue', label: 'Vue', note: 'single-file' },
  { value: 'svelte', label: 'Svelte', note: 'runes' },
  { value: 'solid', label: 'Solid', note: 'signals' },
];
const ROW = 19;
const at = (n: number) => `.radio-cli:has(.radio-cli__opt:nth-child(${n + 3}) input:checked)`;
const status = (label: string) => `Scaffolding ${label} in ./my-app`;
const SPRING = 'linear(0, 0.156, 0.492, 0.838, 1.091, 1.215, 1.228, 1.169, 1.086, 1.012, 0.964, 0.946, 0.951, 0.968, 0.987, 1.002, 1.011, 1.013, 1.01, 1.006, 1.001, 0.998, 0.997, 0.997, 1)';

const css = `
  .radio-cli { width: 262px; overflow: hidden; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 14px 30px -12px light-dark(rgba(0, 0, 0, 0.17), rgba(0, 0, 0, 0.55)); color: light-dark(#5f626a, #a7abb2); font: 500 11px/${ROW}px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; }
  .radio-cli__bar { display: flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border-bottom: 1px solid light-dark(#ebedf0, #232529); }
  .radio-cli__bar i { width: 9px; height: 9px; border-radius: 50%; background: light-dark(#dbdde1, #2e3035); }
  .radio-cli__bar span { margin-left: auto; margin-right: auto; padding-right: 40px; color: light-dark(#6d7179, #7d8087); font-size: 10px; }
  .radio-cli__body { padding: 8px 12px 10px; }
  .radio-cli__line { display: block; white-space: nowrap; }
  .radio-cli__line b { color: light-dark(#373a40, #f1f2f3); font-weight: 700; }
  .radio-cli__dim { color: light-dark(#6d7179, #7d8087); }
  .radio-cli__opts { position: relative; display: block; }
  /* The caret and the band behind the line ride one spring down the list. */
  .radio-cli__caret, .radio-cli__band { position: absolute; left: 0; top: 0; height: ${ROW}px; transform: translateY(calc(var(--i, 0) * ${ROW}px)); transition: transform 0.62s ${SPRING}; pointer-events: none; }
  .radio-cli__caret { width: 12px; color: light-dark(#373a40, #f1f2f3); font-weight: 700; }
  .radio-cli__band { left: -12px; right: -12px; background: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 7%, transparent); box-shadow: inset 2px 0 0 light-dark(#50535b, #c6c8cd); }
  ${OPTIONS.map((_, i) => `${at(i)} { --i: ${i}; }`).join('\n  ')}
  .radio-cli__opt { position: relative; display: flex; align-items: center; gap: 8px; padding-left: 18px; cursor: pointer; transition: color 0.2s; }
  .radio-cli__opt:hover { color: light-dark(#42454c, #e5e6e9); }
  .radio-cli__opt:has(input:checked) { color: light-dark(#373a40, #f1f2f3); }
  .radio-cli__opt:has(input:focus-visible) .radio-cli__name { outline: 1.5px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; border-radius: 2px; }
  .radio-cli__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  .radio-cli__dot { position: relative; width: 9px; height: 9px; border: 1.5px solid currentColor; border-radius: 50%; }
  .radio-cli__dot::after { content: ''; position: absolute; inset: 1.5px; border-radius: 50%; background: currentColor; transform: scale(0); transition: transform 0.15s; }
  .radio-cli__opt:has(input:checked) .radio-cli__dot::after { transform: scale(1); transition: transform 0.4s ${SPRING} 0.12s; }
  .radio-cli__name { min-width: 46px; font-weight: 700; }
  .radio-cli__note { color: light-dark(#6d7179, #7d8087); transition: opacity 0.2s; opacity: 0; }
  .radio-cli__opt:has(input:checked) .radio-cli__note, .radio-cli__opt:hover .radio-cli__note { opacity: 1; }
  /* The next step types itself out for whatever is picked, cursor in tow. */
  .radio-cli__status { display: flex; align-items: center; margin-top: 2px; color: light-dark(#50535b, #c6c8cd); }
  .radio-cli__status > .radio-cli__mark { width: 18px; flex: none; color: light-dark(#373a40, #f1f2f3); }
  .radio-cli__typed { display: grid; }
  .radio-cli__typed > span { grid-area: 1 / 1; display: none; max-width: 0; overflow: hidden; white-space: nowrap; }
  ${OPTIONS.map((_, i) => `${at(i)} .radio-cli__typed > :nth-child(${i + 1})`).join(',\n  ')} { display: block; animation: radio-cli-type calc(var(--n) * 24ms) steps(var(--n)) 0.3s forwards; }
  @keyframes radio-cli-type {
    to { max-width: calc(var(--n) * 1ch); }
  }
  .radio-cli__cursor { width: 7px; height: 13px; margin-left: 1px; background: light-dark(#50535b, #c6c8cd); animation: radio-cli-blink 1s steps(1) infinite; }
  @keyframes radio-cli-blink {
    50% { opacity: 0; }
  }
  @media (prefers-reduced-motion: reduce) {
    .radio-cli *, .radio-cli *::after { animation: none !important; transition: none !important; }
    .radio-cli__typed > span { max-width: none; }
  }
`;

export function Preview() {
  const name = useId();
  return (
    <>
      <style href="kit-radios-cli" precedence="kit">
        {css}
      </style>
      <div className="radio-cli">
        <div className="radio-cli__bar" aria-hidden="true">
          <i />
          <i />
          <i />
          <span>~/my-app</span>
        </div>
        <div className="radio-cli__body">
          <span className="radio-cli__line" aria-hidden="true">
            <span className="radio-cli__dim">$</span> npm create movin@latest
          </span>
          <span className="radio-cli__line" id={`${name}-q`}>
            <b>?</b> Which framework? <span className="radio-cli__dim">↑↓</span>
          </span>
          <div className="radio-cli__opts" role="radiogroup" aria-labelledby={`${name}-q`}>
            <span className="radio-cli__band" aria-hidden="true" />
            <span className="radio-cli__caret" aria-hidden="true">
              ❯
            </span>
            {OPTIONS.map((o, i) => (
              <label key={o.value} className="radio-cli__opt">
                <input className="radio-cli__input" type="radio" name={name} value={o.value} defaultChecked={i === 0} />
                <span className="radio-cli__dot" aria-hidden="true" />
                <span className="radio-cli__name">{o.label}</span>
                <span className="radio-cli__note" aria-hidden="true">
                  {o.note}
                </span>
              </label>
            ))}
          </div>
          <span className="radio-cli__status" aria-hidden="true">
            <span className="radio-cli__mark">◇</span>
            <span className="radio-cli__typed">
              {OPTIONS.map((o) => (
                <span key={o.value} style={{ '--n': status(o.label).length } as CSSProperties}>
                  {status(o.label)}
                </span>
              ))}
            </span>
            <span className="radio-cli__cursor" />
          </span>
        </div>
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/radio-cli
Source on GitHub

About this component

A framework picker that is a real terminal prompt: the caret and the highlight spring down to the line you pick, its dot fills, and the next step types itself out with a blinking cursor.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, spring, keyframe
Techniques
css variables, css transition, keyframes, spring
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

The question line labels the role="radiogroup" (aria-labelledby) around native radio inputs sharing one name, so the arrow keys move the caret exactly as in a real prompt; focus outlines the option's name. The window chrome, caret and status line are aria-hidden. Under prefers-reduced-motion the caret jumps and the line appears whole.

More radios

See all
Contact Sheet
Eye Chart
Fairground
Fortune