09ステップ式チュートリアル窓
3つのステップが順に課題を出し、正しいコマンドを打つと次へ進みます。進み具合は scaleX で伸びるバーで見せ、正解か不正解かは aria-live で一度読み上げられます。
使いどころ開発ツールのオンボーディング、学習サイトの実習ウィンドウ
aria-livescaleXtransform-origin

<main class="stage">
<div class="tu is-demo" id="tu" data-step="1">
<p class="tu__head">
<span class="tu__badge" aria-hidden="true"><span class="tu__no"></span> / 3</span>
<span class="tu__slot">
<span class="tu__task" data-step="1">今いるフォルダを見ます。pwd と打って Enter</span>
<span class="tu__task" data-step="2">隠しファイルまで見ます。ls -a と打って Enter</span>
<span class="tu__task" data-step="3">変更されたファイルを見ます。git status と打って Enter</span>
</span>
</p>
<p class="tu__rail" role="progressbar" aria-valuemin="0" aria-valuemax="3" aria-valuenow="1" aria-label="進んだステップ">
<span class="tu__fill" aria-hidden="true"></span>
</p>
<form class="tu__win" id="form">
<span class="tu__sign" aria-hidden="true">$</span>
<input class="tu__input" id="ans" type="text" autocomplete="off" spellcheck="false" placeholder="ここにコマンドを入力" aria-label="コマンド入力欄">
<span class="tu__say tu__say--ok" id="ok" aria-live="polite">正解</span>
<span class="tu__say tu__say--no" id="no" aria-live="polite">もう一度</span>
</form>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 단계별 튜토리얼 창 — 세 단계가 차례로 과제를 내고, 맞는 명령을 치면 다음 단계로 넘어간다.
// 과제 세 줄은 같은 격자 칸(grid-area: 1 / 1)에 겹쳐 두고 data-step 으로 하나만 보인다.
// 진행 막대는 채움 요소를 scaleX 로 늘린다 — 왼쪽 변에서 자라야 하므로 transform-origin 을 그쪽에 둔다
// (blog-demo 규칙 8 에서 비중심 원점을 써도 되는 「붙는 지점이 있는 요소」).
// 맞음·틀림은 색만이 아니라 글자로도 말한다. 두 칸 다 aria-live="polite" 라 보이게 된 쪽만 읽힌다.
$duration: $dur-base;
$easing: $ease-spring;
$color: $chart-3;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .18);
$dim: rgba(255, 255, 255, .62);
$ink-soft: rgba(23, 20, 26, .72);
// 단계 번호는 글자가 아니라 등록한 커스텀 속성을 counter() 로 찍는다 —
// 미리보기는 CSS 만으로 도는데 번호를 글자로 두면 과제는 2단계인데 배지는 1 로 남는다
@property --step {
syntax: "<integer>";
inherits: true;
initial-value: 1;
}
@include stage($stage-paper);
.tu {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 408px); font-family: $mono;
}
.tu__head { display: flex; align-items: flex-start; gap: $sp-2; margin: 0; }
.tu__badge {
flex: 0 0 auto; box-sizing: border-box; padding: 0 $sp-2; border-radius: $r-pill;
background: $subject-ink; color: $subject-cream;
font-weight: 800; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.8;
font-variant-numeric: tabular-nums;
}
.tu__slot { display: grid; flex: 1 1 auto; min-width: 0; }
.tu__slot > * { grid-area: 1 / 1; }
.tu__task {
color: $ink-soft; font-weight: 700; font-size: clamp(9px, 2.6vw, 11px); line-height: 1.5;
visibility: hidden;
}
.tu[data-step="2"] { --step: 2; }
.tu[data-step="3"] { --step: 3; }
.tu__no::after { counter-reset: s var(--step); content: counter(s); }
.tu[data-step="1"] .tu__task[data-step="1"],
.tu[data-step="2"] .tu__task[data-step="2"],
.tu[data-step="3"] .tu__task[data-step="3"] { visibility: visible; }
.tu__rail {
margin: 0; height: 4px; border-radius: $r-pill; overflow: hidden;
background: rgba(23, 20, 26, .12);
}
.tu__fill {
display: block; height: 100%; border-radius: $r-pill; background: $color;
transform: scaleX(.3334); transform-origin: left center;
transition: transform $duration $easing;
}
.tu[data-step="2"] .tu__fill { transform: scaleX(.6667); }
.tu[data-step="3"] .tu__fill { transform: scaleX(1); }
.tu__win {
display: flex; align-items: center; gap: $sp-2;
box-sizing: border-box; padding: $sp-2 $sp-3;
border: 1px solid $line; border-radius: $r-card; background: $subject-ink;
}
.tu__sign { flex: 0 0 auto; color: $color; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1.4; }
.tu__input {
flex: 1 1 auto; min-width: 0; box-sizing: border-box;
border: 0; background: none; color: $subject-cream;
font-family: inherit; font-weight: 600; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.5;
}
.tu__input::placeholder { color: rgba(255, 255, 255, .46); }
.tu__input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; border-radius: $r-xs; }
.tu__say {
flex: 0 0 auto; box-sizing: border-box; padding: 0 $sp-2; border-radius: $r-pill;
font-weight: 800; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.8;
transform: scale(.7); transform-origin: 50% 50%; visibility: hidden;
transition: transform $duration $ease-pop;
}
.tu__say--ok { background: $color; color: $stage-ink; }
.tu__say--no { background: $error-soft; color: $subject-ink; }
.tu.is-ok .tu__say--ok, .tu.is-no .tu__say--no { visibility: visible; transform: scale(1); }
// ── 격자 미리보기 루프 (2초)
// 1단계 → 30% 에 2단계 → 62% 에 3단계. 과제 글과 번호는 컷으로 갈고 진행 막대만 연속으로 자란다.
// 되돌림은 0% 경계에서만 일어나 복귀 프레임이 poster 로 뽑히지 않는다 (blog-demo 「복귀 90%」)
.tu.is-demo .tu__task,
.tu.is-demo .tu__badge {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.tu.is-demo .tu__task[data-step="1"] { animation-name: tuTask1; }
.tu.is-demo .tu__task[data-step="2"] { animation-name: tuTask2; }
.tu.is-demo .tu__task[data-step="3"] { animation-name: tuTask3; }
@keyframes tuTask1 {
0%, 29% { visibility: visible; }
30%, 100% { visibility: hidden; }
}
@keyframes tuTask2 {
0%, 29% { visibility: hidden; }
30%, 61% { visibility: visible; }
62%, 100% { visibility: hidden; }
}
@keyframes tuTask3 {
0%, 61% { visibility: hidden; }
62%, 100% { visibility: visible; }
}
.tu.is-demo .tu__badge { animation-name: tuBadge; }
.tu.is-demo {
animation-name: tuStep; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes tuStep {
0%, 29% { --step: 1; }
30%, 61% { --step: 2; }
62%, 100% { --step: 3; }
}
@keyframes tuBadge {
0%, 29% { background: $subject-ink; }
30%, 61% { background: $subject-blue; }
62%, 100% { background: $color; color: $stage-ink; }
}
// 막대만 연속 보간이다 — steps 짝과 한 요소에 섞으면 채움이 컷으로 죽는다 (266-09 실측)
.tu.is-demo .tu__fill {
animation-name: tuFill; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes tuFill {
0%, 29% { transform: scaleX(.3334); }
42%, 61% { transform: scaleX(.6667); }
74%, 100% { transform: scaleX(1); }
}
.tu.is-demo .tu__say--ok {
animation-name: tuOk; animation-duration: $dur-loop; animation-timing-function: $ease-pop;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes tuOk {
0%, 23% { visibility: hidden; transform: scale(.7); }
24% { visibility: visible; transform: scale(.7); }
30%, 55% { visibility: visible; transform: scale(1); }
56%, 100% { visibility: hidden; transform: scale(1); }
}
@media (max-width: 340px) {
.tu { gap: $sp-1; }
.tu__win { padding-left: $sp-2; padding-right: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.tu.is-demo,
.tu.is-demo .tu__task,
.tu.is-demo .tu__badge,
.tu.is-demo .tu__fill,
.tu.is-demo .tu__say--ok { animation: none !important; }
.tu.is-demo .tu__task[data-step="1"] { visibility: visible !important; }
.tu.is-demo .tu__task[data-step="2"],
.tu.is-demo .tu__task[data-step="3"] { visibility: hidden !important; }
.tu__fill, .tu__say { transition: none !important; }
}@property --step {
syntax: "<integer>";
inherits: true;
initial-value: 1;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tu {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 408px);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
}
.tu__head {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0;
}
.tu__badge {
flex: 0 0 auto;
box-sizing: border-box;
padding: 0 8px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.8;
font-variant-numeric: tabular-nums;
}
.tu__slot {
display: grid;
flex: 1 1 auto;
min-width: 0;
}
.tu__slot > * {
grid-area: 1/1;
}
.tu__task {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.5;
visibility: hidden;
}
.tu[data-step="2"] {
--step: 2;
}
.tu[data-step="3"] {
--step: 3;
}
.tu__no::after {
counter-reset: s var(--step);
content: counter(s);
}
.tu[data-step="1"] .tu__task[data-step="1"],
.tu[data-step="2"] .tu__task[data-step="2"],
.tu[data-step="3"] .tu__task[data-step="3"] {
visibility: visible;
}
.tu__rail {
margin: 0;
height: 4px;
border-radius: 999px;
overflow: hidden;
background: rgba(23, 20, 26, 0.12);
}
.tu__fill {
display: block;
height: 100%;
border-radius: 999px;
background: #2b9d74;
transform: scaleX(0.3334);
transform-origin: left center;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tu[data-step="2"] .tu__fill {
transform: scaleX(0.6667);
}
.tu[data-step="3"] .tu__fill {
transform: scaleX(1);
}
.tu__win {
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 16px;
background: #17141a;
}
.tu__sign {
flex: 0 0 auto;
color: #2b9d74;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.4;
}
.tu__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
border: 0;
background: none;
color: #fff7e6;
font-family: inherit;
font-weight: 600;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.5;
}
.tu__input::placeholder {
color: rgba(255, 255, 255, 0.46);
}
.tu__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.tu__say {
flex: 0 0 auto;
box-sizing: border-box;
padding: 0 8px;
border-radius: 999px;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.8;
transform: scale(0.7);
transform-origin: 50% 50%;
visibility: hidden;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tu__say--ok {
background: #2b9d74;
color: #17141a;
}
.tu__say--no {
background: #ffe0d8;
color: #17141a;
}
.tu.is-ok .tu__say--ok, .tu.is-no .tu__say--no {
visibility: visible;
transform: scale(1);
}
.tu.is-demo .tu__task,
.tu.is-demo .tu__badge {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tu.is-demo .tu__task[data-step="1"] {
animation-name: tuTask1;
}
.tu.is-demo .tu__task[data-step="2"] {
animation-name: tuTask2;
}
.tu.is-demo .tu__task[data-step="3"] {
animation-name: tuTask3;
}
@keyframes tuTask1 {
0%, 29% {
visibility: visible;
}
30%, 100% {
visibility: hidden;
}
}
@keyframes tuTask2 {
0%, 29% {
visibility: hidden;
}
30%, 61% {
visibility: visible;
}
62%, 100% {
visibility: hidden;
}
}
@keyframes tuTask3 {
0%, 61% {
visibility: hidden;
}
62%, 100% {
visibility: visible;
}
}
.tu.is-demo .tu__badge {
animation-name: tuBadge;
}
.tu.is-demo {
animation-name: tuStep;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes tuStep {
0%, 29% {
--step: 1;
}
30%, 61% {
--step: 2;
}
62%, 100% {
--step: 3;
}
}
@keyframes tuBadge {
0%, 29% {
background: #17141a;
}
30%, 61% {
background: #2f6df6;
}
62%, 100% {
background: #2b9d74;
color: #17141a;
}
}
.tu.is-demo .tu__fill {
animation-name: tuFill;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes tuFill {
0%, 29% {
transform: scaleX(0.3334);
}
42%, 61% {
transform: scaleX(0.6667);
}
74%, 100% {
transform: scaleX(1);
}
}
.tu.is-demo .tu__say--ok {
animation-name: tuOk;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes tuOk {
0%, 23% {
visibility: hidden;
transform: scale(0.7);
}
24% {
visibility: visible;
transform: scale(0.7);
}
30%, 55% {
visibility: visible;
transform: scale(1);
}
56%, 100% {
visibility: hidden;
transform: scale(1);
}
}
@media (max-width: 340px) {
.tu {
gap: 4px;
}
.tu__win {
padding-left: 8px;
padding-right: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.tu.is-demo,
.tu.is-demo .tu__task,
.tu.is-demo .tu__badge,
.tu.is-demo .tu__fill,
.tu.is-demo .tu__say--ok {
animation: none !important;
}
.tu.is-demo .tu__task[data-step="1"] {
visibility: visible !important;
}
.tu.is-demo .tu__task[data-step="2"],
.tu.is-demo .tu__task[data-step="3"] {
visibility: hidden !important;
}
.tu__fill, .tu__say {
transition: none !important;
}
}var tu = document.getElementById('tu');
var form = document.getElementById('form');
var ans = document.getElementById('ans');
var rail = tu.querySelector('.tu__rail');
var WANT = ['pwd', 'ls -a', 'git status'];
var step = 1;
function show(state) {
tu.classList.remove('is-ok', 'is-no');
if (state) { tu.classList.add(state); }
}
form.addEventListener('submit', function (e) {
e.preventDefault();
tu.classList.remove('is-demo');
// 사이 공백이 몇 칸이든 같은 명령으로 본다
var typed = ans.value.trim().replace(/\s+/g, ' ');
if (typed !== WANT[step - 1]) { show('is-no'); return; }
show('is-ok');
ans.value = '';
if (step < WANT.length) {
step += 1;
tu.setAttribute('data-step', String(step));
rail.setAttribute('aria-valuenow', String(step));
}
});
ans.addEventListener('input', function () { show(''); });// TutorialSteps — vanilla 판(run/294/demo/09-terminal-tutorial-steps/index.html) 의 React 포트.
// 세 단계가 차례로 과제를 내고, 맞는 명령을 치면 다음 단계로 넘어간다.
// 단계 번호는 글자가 아니라 data-step 이 세운 --step 을 counter() 로 찍는다 (module.scss).
// 격자 미리보기(isDemo)는 명령을 한 번 보내면 꺼진다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TutorialSteps.module.scss";
export type TutorialStepsProps = {
/** 격자 미리보기용 2초 자동 루프. 명령을 보내면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// React 이벤트 제네릭이 없는 환경이라 쓰는 필드만 지역 타입으로 선언한다
type FormEvt = { preventDefault(): void };
type ChangeEvt = { target: { value: string } };
type Verdict = "" | "ok" | "no";
const WANT = ["pwd", "ls -a", "git status"];
const TASKS = [
"지금 폴더를 봅니다. pwd 를 치고 Enter",
"숨은 파일까지 봅니다. ls -a 를 치고 Enter",
"바뀐 파일을 봅니다. git status 를 치고 Enter",
];
export default function TutorialSteps({
demo = true,
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2b9d74",
}: TutorialStepsProps) {
const [live, setLive] = useState(demo);
const [step, setStep] = useState(1);
const [verdict, setVerdict] = useState<Verdict>("");
const [value, setValue] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function handleSubmit(e: FormEvt) {
e.preventDefault();
setLive(false);
// 사이 공백이 몇 칸이든 같은 명령으로 본다
const typed = value.trim().replace(/\s+/g, " ");
if (typed !== WANT[step - 1]) { setVerdict("no"); return; }
setVerdict("ok");
setValue("");
if (step < WANT.length) { setStep(step + 1); }
}
function handleChange(e: ChangeEvt) {
setValue(e.target.value);
setVerdict("");
}
const cls = [
styles.root,
live ? styles.isDemo : "",
verdict === "ok" ? styles.isOk : "",
verdict === "no" ? styles.isNo : "",
].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls} data-step={String(step)}>
<p className={styles.head}>
<span className={styles.badge} aria-hidden="true"><span className={styles.no} />{" / 3"}</span>
<span className={styles.slot}>
{TASKS.map((task, i) => (
<span className={styles.task} data-step={String(i + 1)} key={WANT[i]}>{task}</span>
))}
</span>
</p>
<p
className={styles.rail}
role="progressbar"
aria-valuemin={0}
aria-valuemax={3}
aria-valuenow={step}
aria-label="진행한 단계"
>
<span className={styles.fill} aria-hidden="true" />
</p>
<form className={styles.win} onSubmit={handleSubmit}>
<span className={styles.sign} aria-hidden="true">$</span>
<input
className={styles.input}
type="text"
autoComplete="off"
spellCheck={false}
placeholder="여기에 명령을 치세요"
aria-label="명령 입력칸"
value={value}
onChange={handleChange}
/>
<span className={styles.say + " " + styles.sayOk} role="status">맞았어요</span>
<span className={styles.say + " " + styles.sayNo} role="status">다시 한 번</span>
</form>
</div>
</main>
);
}09 ステップ式チュートリアル窓 — 計測 累積面積 3.102% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · ターミナル風 UI 9選 — コピペで動くCLIパーツ
このデモが属する記事
ターミナル風 UI 9選 — コピペで動くCLIパーツターミナル風の UI とは、Web ページの中に黒いコマンド画面のような部品を組み込んだ見た目のことです。ここに並ぶ9つは、インストール行のコピー箱からログ窓・文字で描く進捗バー・サイズを変えられる窓・チュートリアル窓まで、貼り付ければその この記事のデモをすべて見る


