Cover · frame 14ACover · frame 15ACover · frame 16ACover · frame 17ACover · frame 18ACover · frame 19A
CLI
? Which framework? ↑↓
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>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="radio-cli">
<div class="radio-cli__bar" aria-hidden="true">
<i></i>
<i></i>
<i></i>
<span>~/my-app</span>
</div>
<div class="radio-cli__body">
<span class="radio-cli__line" aria-hidden="true">
<span class="radio-cli__dim">$</span>
npm create movin@latest
</span>
<span class="radio-cli__line" id="radio-cli-q">
<b>?</b>
Which framework?
<span class="radio-cli__dim">↑↓</span>
</span>
<div class="radio-cli__opts" role="radiogroup" aria-labelledby="radio-cli-q">
<span class="radio-cli__band" aria-hidden="true"></span>
<span class="radio-cli__caret" aria-hidden="true">❯</span>
<label class="radio-cli__opt">
<input class="radio-cli__input" type="radio" name="radio-cli" checked="" value="react"/>
<span class="radio-cli__dot" aria-hidden="true"></span>
<span class="radio-cli__name">React</span>
<span class="radio-cli__note" aria-hidden="true">hooks</span>
</label>
<label class="radio-cli__opt">
<input class="radio-cli__input" type="radio" name="radio-cli" value="vue"/>
<span class="radio-cli__dot" aria-hidden="true"></span>
<span class="radio-cli__name">Vue</span>
<span class="radio-cli__note" aria-hidden="true">single-file</span>
</label>
<label class="radio-cli__opt">
<input class="radio-cli__input" type="radio" name="radio-cli" value="svelte"/>
<span class="radio-cli__dot" aria-hidden="true"></span>
<span class="radio-cli__name">Svelte</span>
<span class="radio-cli__note" aria-hidden="true">runes</span>
</label>
<label class="radio-cli__opt">
<input class="radio-cli__input" type="radio" name="radio-cli" value="solid"/>
<span class="radio-cli__dot" aria-hidden="true"></span>
<span class="radio-cli__name">Solid</span>
<span class="radio-cli__note" aria-hidden="true">signals</span>
</label>
</div>
<span class="radio-cli__status" aria-hidden="true">
<span class="radio-cli__mark">◇</span>
<span class="radio-cli__typed">
<span style="--n:29">Scaffolding React in ./my-app</span>
<span style="--n:27">Scaffolding Vue in ./my-app</span>
<span style="--n:30">Scaffolding Svelte in ./my-app</span>
<span style="--n:29">Scaffolding Solid in ./my-app</span>
</span>
<span class="radio-cli__cursor"></span>
</span>
</div>
</div>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/19px 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: 19px; transform: translateY(calc(var(--i, 0) * 19px)); transition: transform 0.62s 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); 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); }
.radio-cli:has(.radio-cli__opt:nth-child(3) input:checked) { --i: 0; }
.radio-cli:has(.radio-cli__opt:nth-child(4) input:checked) { --i: 1; }
.radio-cli:has(.radio-cli__opt:nth-child(5) input:checked) { --i: 2; }
.radio-cli:has(.radio-cli__opt:nth-child(6) input:checked) { --i: 3; }
.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 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) 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; }
.radio-cli:has(.radio-cli__opt:nth-child(3) input:checked) .radio-cli__typed > :nth-child(1),
.radio-cli:has(.radio-cli__opt:nth-child(4) input:checked) .radio-cli__typed > :nth-child(2),
.radio-cli:has(.radio-cli__opt:nth-child(5) input:checked) .radio-cli__typed > :nth-child(3),
.radio-cli:has(.radio-cli__opt:nth-child(6) input:checked) .radio-cli__typed > :nth-child(4) { 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; }
}Goal: Create a "CLI" radio.
Visual behavior: 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.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- The scene reacts with :has() on the root, one selector per option (such as :has(.opt:nth-child(2) input:checked)), so no script is needed.
- The caret and the highlighted band are one translateY driven by an index variable that :has() sets per option, eased with a linear() spring so they overshoot and settle.
- The status line holds one span per option; the picked one types out with a stepped max-width keyframe in ch units, with the block cursor sitting after it.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "CLI" radio.
Visual behavior: 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.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- The scene reacts with :has() on the root, one selector per option (such as :has(.opt:nth-child(2) input:checked)), so no script is needed.
- The caret and the highlighted band are one translateY driven by an index variable that :has() sets per option, eased with a linear() spring so they overshoot and settle.
- The status line holds one span per option; the picked one types out with a stepped max-width keyframe in ch units, with the block cursor sitting after it.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.