Motiva
Мои коллекции
← каталог

Terminal

Печатающийся терминал на чистом CSS: команды набираются посимвольно (width в ch + steps), вывод появляется разом, сценарий зациклен без единой строки JS.

cssautointermediate0reduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
lines?TerminalLine[]DEMOСценарий: команды и их вывод, сверху вниз.
charTime?number0.055Секунд на символ при наборе команды.
pause?number0.55Пауза после команды перед выводом, с.
title?string"motiva — zsh"Заголовок окна.
TerminalLine — что внутри
  • text: string— Текст строки (для команды — без приглашения, его рисует сама запись).
  • kind?: "cmd" | "out" | "ok"— Роль: команда ПЕЧАТАЕТСЯ посимвольно, вывод ПОЯВЛЯЕТСЯ разом — как в настоящем терминале.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/terminal.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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