Shell
- movin shell, type help
import { useEffect, useRef, useState } from 'react';
const css = `
@keyframes field-shell-line { from { opacity: 0; transform: translateY(6px); } }
.field-shell {
display: flex;
flex-direction: column;
justify-content: flex-end;
width: 256px;
height: 170px;
padding: 10px 12px;
border-radius: 10px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
font: 12px/18px ui-monospace, SFMono-Regular, Menlo, monospace;
overflow: hidden;
cursor: text;
}
.field-shell:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-shell__log { margin: 0; padding: 0; list-style: none; }
.field-shell__log li { color: light-dark(#50535b, #c6c8cd); white-space: pre-wrap; animation: field-shell-line 0.2s ease-out both; }
.field-shell__log li[data-cmd] { color: light-dark(#373a40, #f1f2f3); }
.field-shell__log li[data-cmd]::before, .field-shell__prompt::before { content: '› '; color: light-dark(#5f626a, #a7abb2); }
.field-shell__log li[data-err] { color: light-dark(#5f626a, #a7abb2); font-style: italic; }
.field-shell__prompt { display: flex; color: light-dark(#373a40, #f1f2f3); }
.field-shell__input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: none; color: light-dark(#373a40, #f1f2f3); font: inherit; caret-color: light-dark(#373a40, #f1f2f3); }
.field-shell__ghost { color: light-dark(#6d7179, #7d8087); }
@media (prefers-reduced-motion: reduce) { .field-shell__log li { animation: none; } }
`;
type Line = { id: number; text: string; kind?: 'cmd' | 'err' };
const COMMANDS = ['help', 'ls', 'date', 'whoami', 'echo', 'theme', 'clear'];
function run(cmd: string): string[] | 'clear' {
const [name, ...rest] = cmd.trim().split(/\s+/);
switch (name) {
case 'help':
return [`commands: ${COMMANDS.join(' ')}`];
case 'ls':
return ['buttons/ cards/ inputs/ loaders/ toggles/'];
case 'date':
return [new Date().toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })];
case 'whoami':
return ['guest@movin.design'];
case 'echo':
return [rest.join(' ')];
case 'theme':
return [`${matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'} (follows your system)`];
case 'clear':
return 'clear';
default:
return [`command not found: ${name}`];
}
}
export function ShellInput() {
const seq = useRef(0);
const [log, setLog] = useState<Line[]>([
{ id: -2, text: 'movin shell, type help', kind: 'err' },
]);
const [value, setValue] = useState('');
const history = useRef<string[]>([]);
const at = useRef(0);
const input = useRef<HTMLInputElement>(null);
useEffect(() => {
at.current = history.current.length;
}, [log]);
const completion = value && !value.includes(' ') ? COMMANDS.find((c) => c.startsWith(value) && c !== value) : undefined;
const submit = () => {
const cmd = value.trim();
setValue('');
if (!cmd) return;
history.current.push(cmd);
const out = run(cmd);
if (out === 'clear') return setLog([]);
const err = out[0].startsWith('command not found');
setLog((l) => [...l, { id: seq.current++, text: cmd, kind: 'cmd' as const }, ...out.map((t) => ({ id: seq.current++, text: t, kind: err ? ('err' as const) : undefined }))].slice(-7));
};
return (
<>
<style>
{css}
</style>
<div className="field-shell" onClick={() => input.current?.focus()}>
<ul className="field-shell__log" aria-live="polite">
{log.map((l, i) => (
<li key={l.id} data-cmd={l.kind === 'cmd' || undefined} data-err={l.kind === 'err' || undefined} style={{ animationDelay: `${(i % 2) * 40}ms` }}>
{l.text}
</li>
))}
</ul>
<div className="field-shell__prompt">
<input
ref={input}
className="field-shell__input"
value={value}
aria-label="Command"
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submit();
if (e.key === 'Tab' && completion) {
e.preventDefault();
setValue(completion + ' ');
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const h = history.current;
at.current = Math.max(0, Math.min(h.length, at.current + (e.key === 'ArrowUp' ? -1 : 1)));
setValue(h[at.current] ?? '');
}
}}
style={{ width: completion ? `${value.length + 0.5}ch` : undefined, flex: completion ? 'none' : 1 }}
/>
{completion && (
<span className="field-shell__ghost" aria-hidden="true">
{completion.slice(value.length)}
</span>
)}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldShell(el) on the mounted element instead.
<div class="field-shell">
<ul class="field-shell__log" aria-live="polite"><li data-err="true" style="animation-delay:0ms">movin shell, type help</li></ul>
<div class="field-shell__prompt">
<input class="field-shell__input" aria-label="Command" autoComplete="off" autoCapitalize="off" spellCheck="false" style="flex:1" value=""/>
</div>
</div>@keyframes field-shell-line { from { opacity: 0; transform: translateY(6px); } }
.field-shell {
display: flex;
flex-direction: column;
justify-content: flex-end;
width: 256px;
height: 170px;
padding: 10px 12px;
border-radius: 10px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
font: 12px/18px ui-monospace, SFMono-Regular, Menlo, monospace;
overflow: hidden;
cursor: text;
}
.field-shell:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-shell__log { margin: 0; padding: 0; list-style: none; }
.field-shell__log li { color: light-dark(#50535b, #c6c8cd); white-space: pre-wrap; animation: field-shell-line 0.2s ease-out both; }
.field-shell__log li[data-cmd] { color: light-dark(#373a40, #f1f2f3); }
.field-shell__log li[data-cmd]::before, .field-shell__prompt::before { content: '› '; color: light-dark(#5f626a, #a7abb2); }
.field-shell__log li[data-err] { color: light-dark(#5f626a, #a7abb2); font-style: italic; }
.field-shell__prompt { display: flex; color: light-dark(#373a40, #f1f2f3); }
.field-shell__input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: none; color: light-dark(#373a40, #f1f2f3); font: inherit; caret-color: light-dark(#373a40, #f1f2f3); }
.field-shell__ghost { color: light-dark(#6d7179, #7d8087); }
@media (prefers-reduced-motion: reduce) { .field-shell__log li { animation: none; } }function initFieldShell(root) {
var log = root.querySelector('.field-shell__log');
var input = root.querySelector('.field-shell__input');
var prompt = root.querySelector('.field-shell__prompt');
if (!log || !input || !prompt) return;
var COMMANDS = ['help', 'ls', 'date', 'whoami', 'echo', 'theme', 'clear'];
var history = [];
var at = 0;
var ghost = null;
function run(cmd) {
var parts = cmd.trim().split(/\s+/);
switch (parts[0]) {
case 'help': return ['commands: ' + COMMANDS.join(' ')];
case 'ls': return ['buttons/ cards/ inputs/ loaders/ toggles/'];
case 'date': return [new Date().toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })];
case 'whoami': return ['guest@movin.design'];
case 'echo': return [parts.slice(1).join(' ')];
case 'theme': return [(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + ' (follows your system)'];
case 'clear': return 'clear';
default: return ['command not found: ' + parts[0]];
}
}
function line(text, kind) {
var li = document.createElement('li');
if (kind) li.setAttribute('data-' + kind, '');
li.textContent = text;
log.appendChild(li);
while (log.children.length > 7) log.firstChild.remove();
}
function completion() {
var v = input.value;
if (!v || v.indexOf(' ') >= 0) return null;
for (var i = 0; i < COMMANDS.length; i++) if (COMMANDS[i].indexOf(v) === 0 && COMMANDS[i] !== v) return COMMANDS[i];
return null;
}
function paintGhost() {
var c = completion();
if (ghost) ghost.remove();
ghost = null;
input.style.width = c ? input.value.length + 0.5 + 'ch' : '';
input.style.flex = c ? 'none' : '';
if (!c) return;
ghost = document.createElement('span');
ghost.className = 'field-shell__ghost';
ghost.setAttribute('aria-hidden', 'true');
ghost.textContent = c.slice(input.value.length);
prompt.appendChild(ghost);
}
function submit() {
var cmd = input.value.trim();
input.value = '';
paintGhost();
if (!cmd) return;
history.push(cmd);
at = history.length;
var out = run(cmd);
if (out === 'clear') {
log.textContent = '';
return;
}
var err = out[0].indexOf('command not found') === 0;
line(cmd, 'cmd');
out.forEach(function (t) { line(t, err ? 'err' : null); });
}
function key(e) {
if (e.key === 'Enter') submit();
var c = completion();
if (e.key === 'Tab' && c) {
e.preventDefault();
input.value = c + ' ';
paintGhost();
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
at = Math.max(0, Math.min(history.length, at + (e.key === 'ArrowUp' ? -1 : 1)));
input.value = history[at] || '';
paintGhost();
}
}
function focus() { input.focus(); }
input.addEventListener('keydown', key);
input.addEventListener('input', paintGhost);
root.addEventListener('click', focus);
return function () {
input.removeEventListener('keydown', key);
input.removeEventListener('input', paintGhost);
root.removeEventListener('click', focus);
};
}
document.querySelectorAll('.field-shell').forEach(initFieldShell);Goal: Create a "Shell" input.
Visual behavior: A tiny terminal: type commands like help, ls, date, whoami, echo or theme and the output slides up, Tab completes a command name shown as a ghost, the arrows recall history and clear wipes the screen.
Technique:
- The log is a list pinned to the bottom of the box; each command and its output lines slide up on a keyframe, and only the last seven lines are kept.
- A small table maps commands to output; unknown commands print "command not found".
- While typing a command name, the rest of the best match shows as a ghost after the caret and Tab accepts it; Up and Down walk the history.
Accessibility: The input has an aria-label; the log is an aria-live region, so output is announced. Clicking anywhere in the box focuses the input. Under prefers-reduced-motion lines appear without sliding.
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 "Shell" input.
Visual behavior: A tiny terminal: type commands like help, ls, date, whoami, echo or theme and the output slides up, Tab completes a command name shown as a ghost, the arrows recall history and clear wipes the screen.
Technique:
- The log is a list pinned to the bottom of the box; each command and its output lines slide up on a keyframe, and only the last seven lines are kept.
- A small table maps commands to output; unknown commands print "command not found".
- While typing a command name, the rest of the best match shows as a ghost after the caret and Tab accepts it; Up and Down walk the history.
Accessibility: The input has an aria-label; the log is an aria-live region, so output is announced. Clicking anywhere in the box focuses the input. Under prefers-reduced-motion lines appear without sliding.
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 tiny terminal: type commands like help, ls, date, whoami, echo or theme and the output slides up, Tab completes a command name shown as a ghost, the arrows recall history and clear wipes the screen.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, state change
- Techniques
- keyframes, state machine, text animation
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The input has an aria-label; the log is an aria-live region, so output is announced. Clicking anywhere in the box focuses the input. Under prefers-reduced-motion lines appear without sliding.