← каталог
Terminal
Печатающийся терминал на чистом CSS: команды набираются посимвольно (width в ch + steps), вывод появляется разом, сценарий зациклен без единой строки JS.
cssautointermediate0reduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| lines? | TerminalLine[] | DEMO | Сценарий: команды и их вывод, сверху вниз. |
| charTime? | number | 0.055 | Секунд на символ при наборе команды. |
| pause? | number | 0.55 | Пауза после команды перед выводом, с. |
| title? | string | "motiva — zsh" | Заголовок окна. |
TerminalLine — что внутри
- text: string— Текст строки (для команды — без приглашения, его рисует сама запись).
- kind?: "cmd" | "out" | "ok"— Роль: команда ПЕЧАТАЕТСЯ посимвольно, вывод ПОЯВЛЯЕТСЯ разом — как в настоящем терминале.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/terminal.jsonЧто нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useId } from "react";
import type { CSSProperties } from "react";
export interface TerminalLine {
/** Текст строки (для команды — без приглашения, его рисует сама запись). */
text: string;
/** Роль: команда ПЕЧАТАЕТСЯ посимвольно, вывод ПОЯВЛЯЕТСЯ разом — как в настоящем терминале. */
kind?: "cmd" | "out" | "ok";
}
export interface TerminalProps {
/** Сценарий: команды и их вывод, сверху вниз. */
lines?: TerminalLine[];
/** Секунд на символ при наборе команды. */
charTime?: number;
/** Пауза после команды перед выводом, с. */
pause?: number;
/** Заголовок окна. */
title?: string;
}
const DEMO: TerminalLine[] = [
{ text: "npx shadcn add motiva/terminal", kind: "cmd" },
{ text: "установка зависимостей...", kind: "out" },
{ text: "ok: компонент добавлен за 1.2 c", kind: "ok" },
{ text: "pnpm dev", kind: "cmd" },
{ text: "ready in 240 ms -> localhost:4321", kind: "out" },
];
/**
* Печатающийся терминал на ЧИСТОМ CSS: набор — анимация ширины в ch со steps()
* (шаг = символ), окна строк — проценты ОДНОГО общего цикла, поэтому весь сценарий
* повторяется бесконечно без единой строки JS. Команды печатаются посимвольно,
* вывод появляется разом. Высота не дышит: строки существуют с первого кадра,
* клипуется только ширина. Reduced-motion / data-shot — терминал напечатан целиком.
*/
export function Terminal({ lines = DEMO, charTime = 0.055, pause = 0.55, title = "motiva — zsh" }: TerminalProps = {}) {
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const script = lines.length ? lines : DEMO;
// раскадровка: команда занимает len*charTime, вывод — короткий такт
let t = 0.6;
const windows = script.map((l) => {
const isCmd = (l.kind ?? "cmd") === "cmd";
const start = t;
const dur = isCmd ? l.text.length * charTime : 0.05;
t += dur + (isCmd ? pause : 0.4);
return { start, dur };
});
const total = t + 2.4; // подержать готовый кадр перед перезапуском
const pct = (s: number) => ((s / total) * 100).toFixed(3);
const kf = script
.map((l, i) => {
const w = windows[i]!;
const len = l.text.length;
if ((l.kind ?? "cmd") === "cmd") {
// steps() лежит в СТАРТОВОМ кадре сегмента: он задаёт тайминг пути до следующего
// кадра; на участке 0→start ширина одинаковая, так что общий блок безопасен.
// tp-* прячет ВСЮ строку (с приглашением) до её окна — иначе одинокий «$»
// будущей команды висит в пустом терминале и спойлерит сценарий
return `
@keyframes tw-${uid}-${i} {
0%, ${pct(w.start)}% { width: 0ch; animation-timing-function: steps(${len}, end); }
${pct(w.start + w.dur)}%, 100% { width: ${len}ch; }
}
@keyframes tp-${uid}-${i} {
0%, ${pct(Math.max(0, w.start - 0.03))}% { opacity: 0; }
${pct(w.start)}%, 100% { opacity: 1; }
}
@keyframes tc-${uid}-${i} {
0%, ${pct(w.start)}% { border-right-color: transparent; }
${pct(w.start + 0.02)}% { border-right-color: oklch(0.85 0.02 265); }
${pct(w.start + w.dur + 0.25)}% { border-right-color: oklch(0.85 0.02 265); }
${pct(w.start + w.dur + 0.28)}%, 100% { border-right-color: transparent; }
}`;
}
return `
@keyframes tw-${uid}-${i} {
0%, ${pct(w.start)}% { opacity: 0; }
${pct(w.start + w.dur)}%, 100% { opacity: 1; }
}`;
})
.join("\n");
const colors: Record<string, string> = {
cmd: "oklch(0.92 0.01 265)",
out: "oklch(0.7 0.02 265)",
ok: "oklch(0.75 0.13 150)",
};
return (
<div
style={{
width: "100%",
borderRadius: 14,
overflow: "hidden",
border: "1px solid oklch(0.3 0.02 270)",
background: "oklch(0.16 0.015 270)",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "0.85rem",
}}
>
<style>{`
${kf}
.tl-${uid} span[data-typed] {
display: inline-block;
overflow: hidden;
white-space: pre;
vertical-align: bottom;
border-right: 0.55ch solid transparent;
}
@media (prefers-reduced-motion: reduce) {
.tl-${uid} span[data-typed], .tl-${uid} [data-line] { animation: none !important; }
}
`}</style>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 14px",
borderBottom: "1px solid oklch(0.26 0.02 270)",
}}
>
{["oklch(0.65 0.19 25)", "oklch(0.8 0.15 90)", "oklch(0.72 0.17 150)"].map((c) => (
<span key={c} style={{ width: 11, height: 11, borderRadius: 999, background: c }} />
))}
<span style={{ marginLeft: 8, fontSize: "0.72rem", color: "oklch(0.6 0.02 265)" }}>{title}</span>
</div>
<div className={`tl-${uid}`} style={{ padding: "14px 16px", lineHeight: 1.9 }}>
{script.map((l, i) => {
const kind = l.kind ?? "cmd";
return kind === "cmd" ? (
<div
key={i}
data-line=""
style={{ color: colors.cmd, animation: `tp-${uid}-${i} ${total}s linear infinite` }}
>
<span style={{ color: "oklch(0.75 0.13 150)" }}>$ </span>
<span
data-typed=""
style={{ animation: `tw-${uid}-${i} ${total}s linear infinite, tc-${uid}-${i} ${total}s linear infinite` }}
>
{l.text}
</span>
</div>
) : (
<div
key={i}
data-line=""
style={
{
color: colors[kind],
animation: `tw-${uid}-${i} ${total}s linear infinite`,
} as CSSProperties
}
>
{l.text}
</div>
);
})}
</div>
</div>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Magic UI Terminal (MIT)v1.0.0