コードブロック UI 9選 — ハイライターなしで作るコード窓
コードブロックとは、記事やドキュメントに載せるプログラムのコードを読みやすく整える、角の丸い窓のことです。窓がただ飾るだけでなく、コピーできて、大事な行を指せて、長いときは畳める形になっていると、読む人は貼り付けまで一息に進めます。ここではその部品を9つ、色を付けるライブラリーなしで作ります。
01 code-copy-button-state
02 code-language-tab-switch
03 code-line-number-highlight
04 code-diff-added-removed
05 code-expand-collapsed-block
06 code-filename-window-header
07 code-terminal-typing-cursor
08 code-result-preview-split
09 code-inline-token-popover
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 押すとコピー済みと伝えるボタン
- 02 同じ例を言語ごとに切り替えるタブ
- 03 行番号と指し示す行
- 04 追加行と削除行を分けて見る diff
- 05 長ければ畳んで開くコード窓
- 06 ファイル名が付いた窓の頭
- 07 コマンドが自動で打たれるターミナル
- 08 コードと結果を交互に見る窓
- 09 語を指すと意味が出るインラインコード
並び順は難しさではなく、読む人がコードを受け取る順です。まずコピーして手元に置き(01)、自分の書き方に合わせて文法を選び(02)、大事な行を指し示してもらい(03)、何が変わったかを色で読み取ります(04)。長いファイルは畳んで収め(05・06)、コマンドは打ち込まれる様子ごと受け取り(07)、仕上げにその結果を自分の目で確かめ(08)、本文中の言葉にはその場で意味を尋ねます(09)。9つともHTMLとCSS、少しのJavaScriptで組み立ててあり、状態を伝えるボタンの作り方は状態フィードバックのボタンで扱ったものと同じ系統です。コードブロック markdown で書いた断片をそのまま貼っても崩れないよう、色付けはクラスだけで決めています。埋め込みの軸で再生ボタンまで自作する隣人はカスタム動画プレーヤーで、サイドバーに積む一覧型は通知 uiです。
01押すとコピー済みと伝えるボタン
何が見えるか
窓の右上のボタンを押すと navigator.clipboard が中身を受け取り、ラベルが1フレームで「コピーしました」に切り替わります。切り替わった文字は aria-live の領域にも同じように流れるので、画面を見ていない人にもコピーが伝わります。

<main class="stage">
<div class="ccb is-demo" id="root">
<div class="pane">
<div class="pane__head">
<span class="pane__lang">CSS</span>
<div class="pane__actions">
<span class="pane__chip" id="chip" aria-hidden="true">クリップボードに保存</span>
<button class="pane__copy" id="copyBtn" type="button" aria-label="コードをコピー">
<span class="copy__state copy__state--idle" id="stIdle">
<svg class="ic" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>
<span id="labelIdle">コピー</span>
</span>
<span class="copy__state copy__state--done" id="stDone">
<svg class="ic" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span>コピーしました</span>
</span>
</button>
</div>
</div>
<div class="pane__body">
<span class="pane__sel" id="sel" aria-hidden="true"></span>
<pre id="codeText"><code><span class="k">.btn</span> {
<span class="k">display</span>: <span class="s">inline-flex</span>;
<span class="k">align-items</span>: <span class="s">center</span>;
<span class="k">gap</span>: <span class="n">8px</span>;
<span class="k">padding</span>: <span class="n">8px</span> <span class="n">16px</span>;
<span class="k">border-radius</span>: <span class="n">12px</span>;
}</code></pre>
</div>
</div>
<span class="ccb__cursor" id="cursor" aria-hidden="true"></span>
<span class="sr" id="live" role="status" aria-live="polite"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.ccb {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
color: $subject-ink;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
background: rgba(23, 20, 26, .06);
border-bottom: 1px solid rgba(23, 20, 26, .12);
}
.pane__lang {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: .02em;
color: rgba($subject-ink, .72);
text-transform: uppercase;
}
.pane__actions {
display: flex;
align-items: center;
gap: $sp-1;
}
// 칩 — 버튼처럼 안 보이게 배경·알약을 떼고 흐린 글자만 (자리는 opacity·transform 으로만 뜨고 진다)
.pane__chip {
box-sizing: border-box;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
color: rgba(23, 20, 26, .45);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 10px;
font-weight: 700;
opacity: 0;
transform: translateY(8px);
transition: opacity $duration $easing, transform $duration $easing;
}
.pane__copy {
position: relative;
box-sizing: border-box;
width: 76px;
height: 24px;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: inherit;
cursor: pointer;
transition: transform $duration $easing;
}
.pane__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.pane__copy:hover { background: rgba(23, 20, 26, .14); }
.copy__state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-1;
border-radius: $r-pill;
pointer-events: none;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
transition: opacity $duration $easing;
}
.copy__state--idle { opacity: 1; color: $subject-ink; }
.copy__state--done { opacity: 0; background: $color; color: $subject-cream; }
.pane__copy.is-copied .copy__state--idle { opacity: 0; }
.pane__copy.is-copied .copy__state--done { opacity: 1; }
.pane__body {
position: relative;
box-sizing: border-box;
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.pane__body pre, .pane__body code { margin: 0; font: inherit; white-space: pre; }
.k { color: $subject-orange-deep; }
.s { color: $subject-blue-deep; }
.n { color: $error; }
// 전체 선택 하이라이트 — 배경이 아니라 겹친 레이어의 opacity 만 움직인다
.pane__sel {
position: absolute;
inset: $sp-1;
border-radius: $r-xs;
background: rgba($color, .18);
opacity: 0;
pointer-events: none;
}
.ccb__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid $subject-cream;
border-radius: 50%;
background: $subject-ink;
box-shadow: $shadow-press;
pointer-events: none;
opacity: 0;
transform: translate(16px, 128px);
}
.ccb.is-demo .ccb__cursor { opacity: 1; }
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 20% 컷: 복사됨 + 전체 하이라이트 ON(면적 최대) · 86% 컷: 복사로 복귀
.ccb.is-demo .copy__state--idle,
.ccb.is-demo .copy__state--done,
.ccb.is-demo .pane__sel {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ccb.is-demo .copy__state--idle { animation-name: ccb-idle; }
.ccb.is-demo .copy__state--done { animation-name: ccb-done; }
.ccb.is-demo .pane__sel { animation-name: ccb-sel; }
.ccb.is-demo .pane__copy {
animation-name: ccb-press;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ccb.is-demo .pane__chip {
animation-name: ccb-chip;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ccb.is-demo .ccb__cursor {
// 커서 이동만은 오버슈트 없는 이징을 쓴다 — $ease-pop(탄성)은 좌표가 목표치를 넘어 튀어
// 뷰포트 경계(320px 폰 폭)를 순간적으로 넘길 수 있다(실측: 커서 우측 끝 322px)
animation-name: ccb-cursor;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
@keyframes ccb-idle {
0% { opacity: 1; }
20% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes ccb-done {
0% { opacity: 0; }
20% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes ccb-sel {
0% { opacity: 0; }
20% { opacity: 1; }
28% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes ccb-press {
0%, 14% { transform: scale(1); }
18% { transform: scale(.94); }
22%, 100% { transform: scale(1); }
}
@keyframes ccb-chip {
0%, 20% { opacity: 0; transform: translateY(8px); }
22%, 34% { opacity: 1; transform: translateY(0); }
72% { opacity: 1; transform: translateY(0); }
84%, 100% { opacity: 0; transform: translateY(8px); }
}
@keyframes ccb-cursor {
// 쉬는 자리는 버튼(x 200~276, y 2~26) 왼쪽 아래 바깥 8px — 체크 아이콘·글자를 가리지 않는다
0%, 2% { transform: translate(16px, 128px); }
16%, 34% { transform: translate(192px, 34px); }
70%, 100% { transform: translate(16px, 128px); }
}
@media (max-width: 340px) {
.pane { width: 100%; }
.pane__body { font-size: 10px; line-height: 16px; padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.ccb.is-demo .copy__state--idle,
.ccb.is-demo .copy__state--done,
.ccb.is-demo .pane__sel,
.ccb.is-demo .pane__copy,
.ccb.is-demo .pane__chip,
.ccb.is-demo .ccb__cursor { animation: none !important; }
.ccb.is-demo .copy__state--idle { opacity: 0; }
.ccb.is-demo .copy__state--done { opacity: 1; }
.ccb.is-demo .ccb__cursor { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ccb {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
color: #17141a;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 28px;
padding: 0 12px;
background: rgba(23, 20, 26, 0.06);
border-bottom: 1px solid rgba(23, 20, 26, 0.12);
}
.pane__lang {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.pane__actions {
display: flex;
align-items: center;
gap: 4px;
}
.pane__chip {
box-sizing: border-box;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
color: rgba(23, 20, 26, 0.45);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 10px;
font-weight: 700;
opacity: 0;
transform: translateY(8px);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pane__copy {
position: relative;
box-sizing: border-box;
width: 76px;
height: 24px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: inherit;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pane__copy:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pane__copy:hover {
background: rgba(23, 20, 26, 0.14);
}
.copy__state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
border-radius: 999px;
pointer-events: none;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.copy__state--idle {
opacity: 1;
color: #17141a;
}
.copy__state--done {
opacity: 0;
background: #2f6df6;
color: #fff7e6;
}
.pane__copy.is-copied .copy__state--idle {
opacity: 0;
}
.pane__copy.is-copied .copy__state--done {
opacity: 1;
}
.pane__body {
position: relative;
box-sizing: border-box;
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.pane__body pre, .pane__body code {
margin: 0;
font: inherit;
white-space: pre;
}
.k {
color: #c93000;
}
.s {
color: #0a4fe8;
}
.n {
color: #c2361a;
}
.pane__sel {
position: absolute;
inset: 4px;
border-radius: 4px;
background: rgba(47, 109, 246, 0.18);
opacity: 0;
pointer-events: none;
}
.ccb__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid #fff7e6;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
pointer-events: none;
opacity: 0;
transform: translate(16px, 128px);
}
.ccb.is-demo .ccb__cursor {
opacity: 1;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.ccb.is-demo .copy__state--idle,
.ccb.is-demo .copy__state--done,
.ccb.is-demo .pane__sel {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ccb.is-demo .copy__state--idle {
animation-name: ccb-idle;
}
.ccb.is-demo .copy__state--done {
animation-name: ccb-done;
}
.ccb.is-demo .pane__sel {
animation-name: ccb-sel;
}
.ccb.is-demo .pane__copy {
animation-name: ccb-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ccb.is-demo .pane__chip {
animation-name: ccb-chip;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ccb.is-demo .ccb__cursor {
animation-name: ccb-cursor;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes ccb-idle {
0% {
opacity: 1;
}
20% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes ccb-done {
0% {
opacity: 0;
}
20% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ccb-sel {
0% {
opacity: 0;
}
20% {
opacity: 1;
}
28% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ccb-press {
0%, 14% {
transform: scale(1);
}
18% {
transform: scale(0.94);
}
22%, 100% {
transform: scale(1);
}
}
@keyframes ccb-chip {
0%, 20% {
opacity: 0;
transform: translateY(8px);
}
22%, 34% {
opacity: 1;
transform: translateY(0);
}
72% {
opacity: 1;
transform: translateY(0);
}
84%, 100% {
opacity: 0;
transform: translateY(8px);
}
}
@keyframes ccb-cursor {
0%, 2% {
transform: translate(16px, 128px);
}
16%, 34% {
transform: translate(192px, 34px);
}
70%, 100% {
transform: translate(16px, 128px);
}
}
@media (max-width: 340px) {
.pane {
width: 100%;
}
.pane__body {
font-size: 10px;
line-height: 16px;
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ccb.is-demo .copy__state--idle,
.ccb.is-demo .copy__state--done,
.ccb.is-demo .pane__sel,
.ccb.is-demo .pane__copy,
.ccb.is-demo .pane__chip,
.ccb.is-demo .ccb__cursor {
animation: none !important;
}
.ccb.is-demo .copy__state--idle {
opacity: 0;
}
.ccb.is-demo .copy__state--done {
opacity: 1;
}
.ccb.is-demo .ccb__cursor {
opacity: 0;
}
}// 실제 동작: navigator.clipboard 가 없으면 조용히 넘어간다
var root = document.getElementById('root');
var btn = document.getElementById('copyBtn');
var codeText = document.getElementById('codeText');
var live = document.getElementById('live');
var timer = null;
btn.addEventListener('click', function () {
root.classList.remove('is-demo'); // 사람이 누르면 자동 루프를 멈춘다
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(codeText.textContent).catch(function () {});
}
btn.classList.add('is-copied');
live.textContent = 'コピーしました';
clearTimeout(timer);
timer = setTimeout(function () {
btn.classList.remove('is-copied');
live.textContent = '';
}, 1600);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CopyButtonState.module.scss";
export type CopyButtonStateProps = {
langLabel?: string;
idleLabel?: string;
doneLabel?: string;
chipLabel?: string;
ariaLabel?: string;
code?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const Copy = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M216 40v128h-48V88H88V40Z" opacity=".2" />
<path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" />
</g>
</svg>
);
const Check = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" />
<path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" />
</g>
</svg>
);
const DEFAULT_CODE = `.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 12px;
}`;
export default function CopyButtonState({
langLabel = "CSS",
idleLabel = "Copy",
doneLabel = "Copied",
chipLabel = "Saved to clipboard",
ariaLabel = "Copy code",
code = DEFAULT_CODE,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#0a4fe8",
}: CopyButtonStateProps) {
const [live, setLive] = useState(demo);
const [copied, setCopied] = useState(false);
const timer = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timer.current !== null) window.clearTimeout(timer.current);
};
}, []);
function onClick() {
setLive(false);
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(code).catch(() => {});
}
setCopied(true);
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), 1600);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={[styles.ccb, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div className={styles.pane}>
<div className={styles.paneHead}>
<span className={styles.paneLang}>{langLabel}</span>
<div className={styles.paneActions}>
<span className={styles.paneChip} aria-hidden="true">{chipLabel}</span>
<button
className={[styles.paneCopy, !live && copied ? styles.isCopied : ""].join(" ")}
type="button"
aria-label={ariaLabel}
onClick={onClick}
>
<span className={[styles.copyState, styles.copyStateIdle].join(" ")}>
<Copy />
<span>{idleLabel}</span>
</span>
<span className={[styles.copyState, styles.copyStateDone].join(" ")}>
<Check />
<span>{doneLabel}</span>
</span>
</button>
</div>
</div>
<div className={styles.paneBody}>
<span className={styles.paneSel} aria-hidden="true" />
<pre>
<code>
<span className={styles.k}>.btn</span> {"{"}
{"\n "}
<span className={styles.k}>display</span>: <span className={styles.s}>inline-flex</span>;
{"\n "}
<span className={styles.k}>align-items</span>: <span className={styles.s}>center</span>;
{"\n "}
<span className={styles.k}>gap</span>: <span className={styles.n}>8px</span>;
{"\n "}
<span className={styles.k}>padding</span>: <span className={styles.n}>8px</span> <span className={styles.n}>16px</span>;
{"\n "}
<span className={styles.k}>border-radius</span>: <span className={styles.n}>12px</span>;
{"\n}"}
</code>
</pre>
</div>
</div>
<span className={styles.ccbCursor} aria-hidden="true" />
<span className={styles.sr} role="status" aria-live="polite">{!live && copied ? doneLabel : ""}</span>
</div>
);
}01 · 押すとコピー済みと伝えるボタン — 計測 累積面積 28.962% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
貼り付けてすぐ試してほしいインストールコマンドの文書に向いています。
btn.addEventListener('click', function () {
root.classList.remove('is-demo'); // 人が押したら自動ループを止める
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(codeText.textContent).catch(function () {});
}
btn.classList.add('is-copied');
live.textContent = 'コピーしました';
clearTimeout(timer);
timer = setTimeout(function () {
btn.classList.remove('is-copied');
live.textContent = '';
}, 1600);
});
02同じ例を言語ごとに切り替えるタブ
ソースコード 表示 のタブは画面でどう見えるか
CSS・SCSS・Tailwind の3つのタブが同じ結果を別の文法で見せ、下の棒が translateX で選んだタブまで滑ります。棒の位置と幅はそのつど getBoundingClientRect で測って最初のタブとの比に換算するので、ラベルの長さが違っても支障はありません。

<main class="stage">
<div class="lts is-demo" id="root">
<div class="pane">
<div class="pane__head">
<div class="tabs" role="tablist" aria-label="言語を選択" id="tablist">
<button class="tab" role="tab" id="tabCss" aria-selected="true" aria-controls="panelCss" data-lang="css" type="button">CSS</button>
<button class="tab" role="tab" id="tabScss" aria-selected="false" aria-controls="panelScss" tabindex="-1" data-lang="scss" type="button">SCSS</button>
<button class="tab" role="tab" id="tabTw" aria-selected="false" aria-controls="panelTw" tabindex="-1" data-lang="tailwind" type="button">Tailwind</button>
<span class="tabs__indicator" id="indicator" aria-hidden="true"></span>
</div>
</div>
<div class="pane__body">
<pre class="panel" id="panelCss" role="tabpanel" aria-labelledby="tabCss"><code><span class="k">.btn</span> {
<span class="k">display</span>: <span class="s">flex</span>;
<span class="k">align-items</span>: <span class="s">center</span>;
<span class="k">gap</span>: <span class="n">8px</span>;
<span class="k">padding</span>: <span class="n">8px</span> <span class="n">16px</span>;
}</code></pre>
<pre class="panel" id="panelScss" role="tabpanel" aria-labelledby="tabScss" hidden><code><span class="k">.btn</span> {
<span class="k">display</span>: <span class="s">flex</span>;
<span class="k">gap</span>: <span class="n">8px</span>;
<span class="k">&:hover</span> { <span class="k">opacity</span>: <span class="n">.8</span>; }
}</code></pre>
<pre class="panel" id="panelTw" role="tabpanel" aria-labelledby="tabTw" hidden><code><span class="c"><button class="</span>
<span class="s">flex items-center gap-2</span>
<span class="s">px-4 py-2 rounded-xl</span>
<span class="c">"></span>
Copy
<span class="c"></button></span></code></pre>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
$syn-key: $stage-orange;
$syn-str: $stage-yellow;
$syn-num: $subject-sky;
$syn-dim: rgba($subject-cream, .72);
$syn-txt: $subject-cream;
@include stage($stage-yellow);
.lts {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
color: $subject-cream;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-2;
background: rgba(255, 247, 230, .06);
border-bottom: 1px solid rgba(255, 247, 230, .12);
}
.tabs {
position: relative;
display: flex;
align-items: stretch;
height: 100%;
}
.tab {
flex: 0 0 auto;
box-sizing: border-box;
height: 100%;
padding: 0 $sp-3;
border: 0;
background: transparent;
color: $syn-dim;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: color $duration $easing;
}
.tab:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.tab[aria-selected="true"] { color: $subject-cream; }
.tabs__indicator {
position: absolute;
left: 0;
bottom: 0;
width: 1px;
height: 2px;
border-radius: $r-xs;
background: $color;
transform-origin: left center; // 규칙8: 탭 언더라인 왼쪽 기준
transform: translateX(0) scaleX(1);
transition: transform $duration $easing;
}
.pane__body {
position: relative;
box-sizing: border-box;
height: 132px;
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.panel {
position: absolute;
inset: $sp-3;
margin: 0;
font: inherit;
white-space: pre;
opacity: 1;
}
.panel[hidden] { display: block; opacity: 0; }
.k { color: $syn-key; }
.s { color: $syn-str; }
.n { color: $syn-num; }
.c { color: $syn-dim; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 인디케이터는 부드럽게 미끄러지고, 코드 본문 세 겹은 steps(1,end) 컷으로 갈아 끼운다
.lts.is-demo .tabs__indicator {
animation-name: lts-indicator;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lts.is-demo #tabCss,
.lts.is-demo #tabScss,
.lts.is-demo #tabTw {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lts.is-demo #tabCss { animation-name: lts-tab-css; }
.lts.is-demo #tabScss { animation-name: lts-tab-scss; }
.lts.is-demo #tabTw { animation-name: lts-tab-tw; }
.lts.is-demo #panelCss,
.lts.is-demo #panelScss,
.lts.is-demo #panelTw {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
display: block;
}
.lts.is-demo #panelCss { animation-name: lts-panel-css; }
.lts.is-demo #panelScss { animation-name: lts-panel-scss; }
.lts.is-demo #panelTw { animation-name: lts-panel-tw; }
// 폭·오프셋은 Playwright getBoundingClientRect 실측값(스크래치패드/261_tabmeasure.py):
// CSS 43.2px @0 · SCSS 49.38px @43.2 · Tailwind 67.05px @92.58 (기준폭 = CSS 43.2px)
@keyframes lts-indicator {
0% { transform: translateX(0) scaleX(1); }
30% { transform: translateX(43.2px) scaleX(1.14); }
60% { transform: translateX(92.58px) scaleX(1.55); }
88% { transform: translateX(0) scaleX(1); }
100% { transform: translateX(0) scaleX(1); }
}
@keyframes lts-tab-css {
0% { color: $subject-cream; }
30% { color: $syn-dim; }
88% { color: $subject-cream; }
100% { color: $subject-cream; }
}
@keyframes lts-tab-scss {
0% { color: $syn-dim; }
30% { color: $subject-cream; }
60% { color: $syn-dim; }
100% { color: $syn-dim; }
}
@keyframes lts-tab-tw {
0% { color: $syn-dim; }
60% { color: $subject-cream; }
88% { color: $syn-dim; }
100% { color: $syn-dim; }
}
@keyframes lts-panel-css {
0% { opacity: 1; }
30% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes lts-panel-scss {
0% { opacity: 0; }
30% { opacity: 1; }
60% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes lts-panel-tw {
0% { opacity: 0; }
60% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.pane { width: 100%; }
.pane__body { font-size: 10px; line-height: 16px; padding: $sp-2; height: 118px; }
.panel { inset: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.lts.is-demo .tabs__indicator,
.lts.is-demo #tabCss,
.lts.is-demo #tabScss,
.lts.is-demo #tabTw,
.lts.is-demo #panelCss,
.lts.is-demo #panelScss,
.lts.is-demo #panelTw { animation: none !important; }
.lts.is-demo #panelScss,
.lts.is-demo #panelTw { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lts {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
color: #fff7e6;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 8px;
background: rgba(255, 247, 230, 0.06);
border-bottom: 1px solid rgba(255, 247, 230, 0.12);
}
.tabs {
position: relative;
display: flex;
align-items: stretch;
height: 100%;
}
.tab {
flex: 0 0 auto;
box-sizing: border-box;
height: 100%;
padding: 0 12px;
border: 0;
background: transparent;
color: rgba(255, 247, 230, 0.72);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tab:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.tab[aria-selected=true] {
color: #fff7e6;
}
.tabs__indicator {
position: absolute;
left: 0;
bottom: 0;
width: 1px;
height: 2px;
border-radius: 4px;
background: #2f6df6;
transform-origin: left center;
transform: translateX(0) scaleX(1);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pane__body {
position: relative;
box-sizing: border-box;
height: 132px;
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.panel {
position: absolute;
inset: 12px;
margin: 0;
font: inherit;
white-space: pre;
opacity: 1;
}
.panel[hidden] {
display: block;
opacity: 0;
}
.k {
color: #ff4d1f;
}
.s {
color: #ffd23f;
}
.n {
color: #6ec8ff;
}
.c {
color: rgba(255, 247, 230, 0.72);
}
.lts.is-demo .tabs__indicator {
animation-name: lts-indicator;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lts.is-demo #tabCss,
.lts.is-demo #tabScss,
.lts.is-demo #tabTw {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lts.is-demo #tabCss {
animation-name: lts-tab-css;
}
.lts.is-demo #tabScss {
animation-name: lts-tab-scss;
}
.lts.is-demo #tabTw {
animation-name: lts-tab-tw;
}
.lts.is-demo #panelCss,
.lts.is-demo #panelScss,
.lts.is-demo #panelTw {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
display: block;
}
.lts.is-demo #panelCss {
animation-name: lts-panel-css;
}
.lts.is-demo #panelScss {
animation-name: lts-panel-scss;
}
.lts.is-demo #panelTw {
animation-name: lts-panel-tw;
}
@keyframes lts-indicator {
0% {
transform: translateX(0) scaleX(1);
}
30% {
transform: translateX(43.2px) scaleX(1.14);
}
60% {
transform: translateX(92.58px) scaleX(1.55);
}
88% {
transform: translateX(0) scaleX(1);
}
100% {
transform: translateX(0) scaleX(1);
}
}
@keyframes lts-tab-css {
0% {
color: #fff7e6;
}
30% {
color: rgba(255, 247, 230, 0.72);
}
88% {
color: #fff7e6;
}
100% {
color: #fff7e6;
}
}
@keyframes lts-tab-scss {
0% {
color: rgba(255, 247, 230, 0.72);
}
30% {
color: #fff7e6;
}
60% {
color: rgba(255, 247, 230, 0.72);
}
100% {
color: rgba(255, 247, 230, 0.72);
}
}
@keyframes lts-tab-tw {
0% {
color: rgba(255, 247, 230, 0.72);
}
60% {
color: #fff7e6;
}
88% {
color: rgba(255, 247, 230, 0.72);
}
100% {
color: rgba(255, 247, 230, 0.72);
}
}
@keyframes lts-panel-css {
0% {
opacity: 1;
}
30% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes lts-panel-scss {
0% {
opacity: 0;
}
30% {
opacity: 1;
}
60% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes lts-panel-tw {
0% {
opacity: 0;
}
60% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.pane {
width: 100%;
}
.pane__body {
font-size: 10px;
line-height: 16px;
padding: 8px;
height: 118px;
}
.panel {
inset: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.lts.is-demo .tabs__indicator,
.lts.is-demo #tabCss,
.lts.is-demo #tabScss,
.lts.is-demo #tabTw,
.lts.is-demo #panelCss,
.lts.is-demo #panelScss,
.lts.is-demo #panelTw {
animation: none !important;
}
.lts.is-demo #panelScss,
.lts.is-demo #panelTw {
opacity: 0;
}
}var root = document.getElementById('root');
var tablist = document.getElementById('tablist');
var indicator = document.getElementById('indicator');
var tabs = [document.getElementById('tabCss'), document.getElementById('tabScss'), document.getElementById('tabTw')];
var panels = [document.getElementById('panelCss'), document.getElementById('panelScss'), document.getElementById('panelTw')];
function moveIndicator(tab) {
var tabsRect = tablist.getBoundingClientRect();
var r = tab.getBoundingClientRect();
var base = tabs[0].getBoundingClientRect().width;
var left = r.left - tabsRect.left;
var scale = r.width / base;
indicator.style.transform = 'translateX(' + left + 'px) scaleX(' + scale + ')';
}
function select(i) {
tabs.forEach(function (t, idx) {
var on = idx === i;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.tabIndex = on ? 0 : -1;
panels[idx].hidden = !on;
});
moveIndicator(tabs[i]);
}
tabs.forEach(function (t, i) {
t.addEventListener('click', function () {
root.classList.remove('is-demo');
select(i);
});
});
tablist.addEventListener('keydown', function (e) {
var i = tabs.findIndex(function (t) { return t.getAttribute('aria-selected') === 'true'; });
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
root.classList.remove('is-demo');
var next = e.key === 'ArrowRight' ? (i + 1) % tabs.length : (i - 1 + tabs.length) % tabs.length;
select(next);
tabs[next].focus({ preventScroll: true });
e.preventDefault();
}
});import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./LanguageTabSwitch.module.scss";
export type LanguageTabSwitchProps = {
tablistLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T | null };
type KeyEvt = { key: string; preventDefault(): void };
const LANGS = ["css", "scss", "tailwind"] as const;
type Lang = (typeof LANGS)[number];
const LABELS: Record<Lang, string> = { css: "CSS", scss: "SCSS", tailwind: "Tailwind" };
const TAB_CLASS_KEY: Record<Lang, "tabCss" | "tabScss" | "tabTw"> = { css: "tabCss", scss: "tabScss", tailwind: "tabTw" };
function CssCode() {
return (
<code>
<span className={styles.k}>.btn</span> {"{"}
{"\n "}
<span className={styles.k}>display</span>: <span className={styles.s}>flex</span>;
{"\n "}
<span className={styles.k}>align-items</span>: <span className={styles.s}>center</span>;
{"\n "}
<span className={styles.k}>gap</span>: <span className={styles.n}>8px</span>;
{"\n "}
<span className={styles.k}>padding</span>: <span className={styles.n}>8px</span> <span className={styles.n}>16px</span>;
{"\n}"}
</code>
);
}
function ScssCode() {
return (
<code>
<span className={styles.k}>.btn</span> {"{"}
{"\n "}
<span className={styles.k}>display</span>: <span className={styles.s}>flex</span>;
{"\n "}
<span className={styles.k}>gap</span>: <span className={styles.n}>8px</span>;
{"\n "}
<span className={styles.k}>&:hover</span> {"{ "}
<span className={styles.k}>opacity</span>: <span className={styles.n}>.8</span>; {"}"}
{"\n}"}
</code>
);
}
function TailwindCode() {
return (
<code>
<span className={styles.c}>{'<button class="'}</span>
{"\n "}
<span className={styles.s}>flex items-center gap-2</span>
{"\n "}
<span className={styles.s}>px-4 py-2 rounded-xl</span>
{"\n"}
<span className={styles.c}>{'">'}</span>
{"\n Copy\n"}
<span className={styles.c}>{"</button>"}</span>
</code>
);
}
const CODES: Record<Lang, () => ReactNode> = { css: CssCode, scss: ScssCode, tailwind: TailwindCode };
export default function LanguageTabSwitch({
tablistLabel = "Select language",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: LanguageTabSwitchProps) {
const [live, setLive] = useState(demo);
const [active, setActive] = useState<Lang>("css");
const [ind, setInd] = useState<{ left: number; scale: number }>({ left: 0, scale: 1 });
const tablistRef = useRef<HTMLDivElement>(null) as Box<HTMLDivElement>;
const tabRefs = useRef<Record<Lang, HTMLButtonElement | null>>({ css: null, scss: null, tailwind: null }) as Box<
Record<Lang, HTMLButtonElement | null>
>;
function measure(lang: Lang) {
const list = tablistRef.current;
const map = tabRefs.current;
if (!list || !map) return;
const base = map.css;
const tab = map[lang];
if (!base || !tab) return;
const listRect = list.getBoundingClientRect();
const baseRect = base.getBoundingClientRect();
const tabRect = tab.getBoundingClientRect();
setInd({ left: tabRect.left - listRect.left, scale: tabRect.width / baseRect.width });
}
function select(lang: Lang) {
setLive(false);
setActive(lang);
measure(lang);
}
function onKeyDown(e: KeyEvt) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const i = LANGS.indexOf(active);
const next = e.key === "ArrowRight" ? (i + 1) % LANGS.length : (i - 1 + LANGS.length) % LANGS.length;
select(LANGS[next]);
const btn = tabRefs.current ? tabRefs.current[LANGS[next]] : null;
if (btn) btn.focus({ preventScroll: true });
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const ActiveCode = CODES[active];
function setTabRef(lang: Lang, el: HTMLButtonElement | null) {
const map = tabRefs.current;
if (map) map[lang] = el;
}
return (
<div className={[styles.lts, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div className={styles.pane}>
<div className={styles.paneHead}>
<div
className={styles.tabs}
role="tablist"
aria-label={tablistLabel}
ref={tablistRef}
onKeyDown={onKeyDown}
>
{LANGS.map((lang) => (
<button
key={lang}
className={[styles.tab, styles[TAB_CLASS_KEY[lang]]].join(" ")}
role="tab"
type="button"
aria-selected={live ? undefined : active === lang}
tabIndex={live ? -1 : active === lang ? 0 : -1}
ref={(el: HTMLButtonElement | null) => setTabRef(lang, el)}
onClick={() => select(lang)}
>
{LABELS[lang]}
</button>
))}
<span
className={styles.tabsIndicator}
aria-hidden="true"
style={live ? undefined : { transform: `translateX(${ind.left}px) scaleX(${ind.scale})` }}
/>
</div>
</div>
<div className={styles.paneBody}>
{live ? (
<>
<pre className={[styles.panel, styles.panelCss].join(" ")}>
<CssCode />
</pre>
<pre className={[styles.panel, styles.panelScss].join(" ")}>
<ScssCode />
</pre>
<pre className={[styles.panel, styles.panelTw].join(" ")}>
<TailwindCode />
</pre>
</>
) : (
<pre className={styles.panel}>
<ActiveCode />
</pre>
)}
</div>
</div>
</div>
);
}02 · 同じ例を言語ごとに切り替えるタブ — 計測 累積面積 2.667% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
同じ機能を複数の文法で案内するライブラリーの文書に使います。
function moveIndicator(tab) {
var tabsRect = tablist.getBoundingClientRect();
var r = tab.getBoundingClientRect();
var base = tabs[0].getBoundingClientRect().width;
var left = r.left - tabsRect.left;
var scale = r.width / base;
indicator.style.transform = 'translateX(' + left + 'px) scaleX(' + scale + ')';
}
function select(i) {
tabs.forEach(function (t, idx) {
var on = idx === i;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.tabIndex = on ? 0 : -1;
panels[idx].hidden = !on;
});
moveIndicator(tabs[i]);
}
03行番号と指し示す行
どんな動きか
行番号は文字として打たず counter-increment で数えて ::before に出すため、コピーについてきません。指し示す行は背景色が変わり、その行にポインターを乗せると、なぜ大事なのかを書いた付箋が浮かびます。

<main class="stage">
<div class="lnh is-demo" id="root">
<div class="pane">
<div class="pane__head">
<span class="pane__lang">CSS</span>
</div>
<div class="pane__body">
<pre id="codeText"><code class="lines"><span class="ln">.card {</span><span class="ln"> display: grid;</span><span class="ln is-called" id="calledLine" tabindex="0" aria-describedby="tip"> gap: 12px;</span><span class="ln"> padding: 16px;</span><span class="ln">}</span></code></pre>
</div>
</div>
<div class="tooltip" id="tip" role="tooltip">カード間の余白</div>
<span class="lnh__cursor" id="cursor" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-mint;
$syn-key: $stage-orange;
$syn-str: $stage-yellow;
$syn-num: $subject-sky;
$syn-dim: rgba($subject-cream, .72);
$syn-txt: $subject-cream;
@include stage($stage-orange);
.lnh {
position: relative;
box-sizing: border-box;
padding-bottom: 48px;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
color: $subject-cream;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
background: rgba(255, 247, 230, .06);
border-bottom: 1px solid rgba(255, 247, 230, .12);
}
.pane__lang {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: .02em;
color: $syn-dim;
text-transform: uppercase;
}
.pane__body {
position: relative;
box-sizing: border-box;
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.pane__body pre, .pane__body code { margin: 0; font: inherit; }
.lines { counter-reset: ln; display: block; }
.ln {
position: relative;
display: block;
box-sizing: border-box;
padding-left: 24px;
color: $syn-txt;
white-space: pre;
border-radius: $r-xs;
}
.ln::before {
counter-increment: ln;
content: counter(ln);
position: absolute;
left: 0;
top: 0;
width: 16px;
text-align: right;
color: $syn-dim;
user-select: none;
}
.ln.is-called {
cursor: pointer;
background: rgba($color, .10);
}
.ln.is-called:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
.ln.is-called::after {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
border-radius: $r-xs;
background: $color;
transform-origin: top center; // 규칙8: 하이라이트 막대 위에서
transform: scaleY(0);
}
.lnh.is-open .ln.is-called {
background: rgba($color, .22);
transition: background $duration $easing;
}
.lnh.is-open .ln.is-called::after {
transform: scaleY(1);
transition: transform $duration $easing;
}
.tooltip {
// .pane 은 헤더 28px + 본문(패딩 $sp-3×2 + 5줄×18px) = 142px 로 고정 계산 가능 —
// top:100% 는 .lnh 의 padding-bottom(예약분) 까지 포함해 계산되어 버리므로(Trap #7),
// 반드시 .pane 실측 높이를 px 로 고정해 그 아래에 배치한다
position: absolute;
left: $sp-3;
right: $sp-3;
top: 142px;
margin-top: $sp-2;
box-sizing: border-box;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba(255, 247, 230, .96);
color: $stage-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
box-shadow: $shadow-raised;
opacity: 0;
transform-origin: top center; // 규칙8: 패널 아래변에서 펼침
transform: translateY(6px) scale(.96);
pointer-events: none;
}
.lnh.is-open .tooltip {
opacity: 1;
transform: translateY(0) scale(1);
transition: opacity $duration $easing, transform $duration $easing;
}
.lnh__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid $subject-cream;
border-radius: 50%;
background: $stage-ink;
box-shadow: $shadow-press;
pointer-events: none;
opacity: 0;
transform: translate(16px, 20px);
}
.lnh.is-demo .lnh__cursor { opacity: 1; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 등장(≈9~19%, 0.2s)은 짧은 구간에 몰아 급격하게, 퇴장(≈78~94%)은 넓은 구간에 걸쳐 서서히
// — poster 가 「닫히는 순간」이 아니라 「짚어서 뜨는 순간」이 되도록 한다
.lnh.is-demo .tooltip {
animation-name: lnh-tooltip;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lnh.is-demo .ln.is-called {
animation-name: lnh-bg;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lnh.is-demo .ln.is-called::after {
animation-name: lnh-bar;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lnh.is-demo .lnh__cursor {
animation-name: lnh-cursor;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes lnh-tooltip {
0%, 9% { opacity: 0; transform: translateY(6px) scale(.96); }
19% { opacity: 1; transform: translateY(0) scale(1); }
78% { opacity: 1; transform: translateY(0) scale(1); }
94% { opacity: 0; transform: translateY(6px) scale(.96); }
100% { opacity: 0; transform: translateY(6px) scale(.96); }
}
@keyframes lnh-bg {
0%, 18% { background: rgba($color, .10); }
20% { background: rgba($color, .24); }
74% { background: rgba($color, .24); }
92% { background: rgba($color, .10); }
100% { background: rgba($color, .10); }
}
@keyframes lnh-bar {
0%, 20% { transform: scaleY(0); }
34% { transform: scaleY(1); }
78% { transform: scaleY(1); }
94% { transform: scaleY(0); }
100% { transform: scaleY(0); }
}
@keyframes lnh-cursor {
// 3번 줄 글자(" gap: 12px;")는 절대좌표 x 36~115 에 찍힌다 — 커서는 그 오른쪽 빈칸(x≥130)에서만 훑는다
0%, 2% { transform: translate(16px, 20px); }
18% { transform: translate(150px, 84px); }
34% { transform: translate(180px, 84px); }
52% { transform: translate(140px, 84px); }
72% { transform: translate(170px, 84px); }
86%, 100% { transform: translate(16px, 20px); }
}
@media (max-width: 340px) {
// 좁은 폭에서 .pane 높이 = 헤더 28px + 본문(패딩 $sp-2×2 + 5줄×16px) = 124px
.lnh { padding-bottom: 32px; }
.pane { width: 100%; }
.pane__body { font-size: 10px; line-height: 16px; padding: $sp-2; }
.ln { padding-left: 20px; }
.tooltip {
top: 124px;
margin-top: $sp-1;
left: $sp-2;
right: $sp-2;
padding: $sp-1 $sp-2;
}
}
@media (prefers-reduced-motion: reduce) {
.lnh.is-demo .tooltip,
.lnh.is-demo .ln.is-called,
.lnh.is-demo .ln.is-called::after,
.lnh.is-demo .lnh__cursor { animation: none !important; }
.lnh.is-demo .lnh__cursor { opacity: 0; }
.lnh.is-demo .tooltip { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lnh {
position: relative;
box-sizing: border-box;
padding-bottom: 48px;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
color: #fff7e6;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 12px;
background: rgba(255, 247, 230, 0.06);
border-bottom: 1px solid rgba(255, 247, 230, 0.12);
}
.pane__lang {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(255, 247, 230, 0.72);
text-transform: uppercase;
}
.pane__body {
position: relative;
box-sizing: border-box;
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.pane__body pre, .pane__body code {
margin: 0;
font: inherit;
}
.lines {
counter-reset: ln;
display: block;
}
.ln {
position: relative;
display: block;
box-sizing: border-box;
padding-left: 24px;
color: #fff7e6;
white-space: pre;
border-radius: 4px;
}
.ln::before {
counter-increment: ln;
content: counter(ln);
position: absolute;
left: 0;
top: 0;
width: 16px;
text-align: right;
color: rgba(255, 247, 230, 0.72);
user-select: none;
}
.ln.is-called {
cursor: pointer;
background: rgba(76, 212, 166, 0.1);
}
.ln.is-called:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 1px;
}
.ln.is-called::after {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
border-radius: 4px;
background: #4cd4a6;
transform-origin: top center;
transform: scaleY(0);
}
.lnh.is-open .ln.is-called {
background: rgba(76, 212, 166, 0.22);
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lnh.is-open .ln.is-called::after {
transform: scaleY(1);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tooltip {
position: absolute;
left: 12px;
right: 12px;
top: 142px;
margin-top: 8px;
box-sizing: border-box;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.96);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform-origin: top center;
transform: translateY(6px) scale(0.96);
pointer-events: none;
}
.lnh.is-open .tooltip {
opacity: 1;
transform: translateY(0) scale(1);
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lnh__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid #fff7e6;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
pointer-events: none;
opacity: 0;
transform: translate(16px, 20px);
}
.lnh.is-demo .lnh__cursor {
opacity: 1;
}
.lnh.is-demo .tooltip {
animation-name: lnh-tooltip;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lnh.is-demo .ln.is-called {
animation-name: lnh-bg;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lnh.is-demo .ln.is-called::after {
animation-name: lnh-bar;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lnh.is-demo .lnh__cursor {
animation-name: lnh-cursor;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes lnh-tooltip {
0%, 9% {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
19% {
opacity: 1;
transform: translateY(0) scale(1);
}
78% {
opacity: 1;
transform: translateY(0) scale(1);
}
94% {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
100% {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
}
@keyframes lnh-bg {
0%, 18% {
background: rgba(76, 212, 166, 0.1);
}
20% {
background: rgba(76, 212, 166, 0.24);
}
74% {
background: rgba(76, 212, 166, 0.24);
}
92% {
background: rgba(76, 212, 166, 0.1);
}
100% {
background: rgba(76, 212, 166, 0.1);
}
}
@keyframes lnh-bar {
0%, 20% {
transform: scaleY(0);
}
34% {
transform: scaleY(1);
}
78% {
transform: scaleY(1);
}
94% {
transform: scaleY(0);
}
100% {
transform: scaleY(0);
}
}
@keyframes lnh-cursor {
0%, 2% {
transform: translate(16px, 20px);
}
18% {
transform: translate(150px, 84px);
}
34% {
transform: translate(180px, 84px);
}
52% {
transform: translate(140px, 84px);
}
72% {
transform: translate(170px, 84px);
}
86%, 100% {
transform: translate(16px, 20px);
}
}
@media (max-width: 340px) {
.lnh {
padding-bottom: 32px;
}
.pane {
width: 100%;
}
.pane__body {
font-size: 10px;
line-height: 16px;
padding: 8px;
}
.ln {
padding-left: 20px;
}
.tooltip {
top: 124px;
margin-top: 4px;
left: 8px;
right: 8px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.lnh.is-demo .tooltip,
.lnh.is-demo .ln.is-called,
.lnh.is-demo .ln.is-called::after,
.lnh.is-demo .lnh__cursor {
animation: none !important;
}
.lnh.is-demo .lnh__cursor {
opacity: 0;
}
.lnh.is-demo .tooltip {
opacity: 0;
}
}var root = document.getElementById('root');
var line = document.getElementById('calledLine');
function open() {
root.classList.remove('is-demo');
root.classList.add('is-open');
}
function close() {
root.classList.remove('is-open');
}
line.addEventListener('mouseenter', open);
line.addEventListener('mouseleave', close);
line.addEventListener('focus', open);
line.addEventListener('blur', close);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LineNumberHighlight.module.scss";
export type LineNumberHighlightProps = {
langLabel?: string;
tooltipText?: string;
/** Auto-play loop (for gallery). Turns off on real interaction */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const LINES = [".card {", " display: grid;", " gap: 12px;", " padding: 16px;", "}"];
const CALLED_INDEX = 2;
export default function LineNumberHighlight({
langLabel = "CSS",
tooltipText = "Gap between cards",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: LineNumberHighlightProps) {
const [live, setLive] = useState(demo);
const [open, setOpen] = useState(false);
const [tipId] = useState(() => "lnh-tip-" + Math.random().toString(36).slice(2));
function show() {
setLive(false);
setOpen(true);
}
function hide() {
setOpen(false);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={[styles.lnh, live ? styles.isDemo : "", open ? styles.isOpen : ""].join(" ")} style={vars}>
<div className={styles.pane}>
<div className={styles.paneHead}>
<span className={styles.paneLang}>{langLabel}</span>
</div>
<div className={styles.paneBody}>
<pre>
<code className={styles.lines}>
{LINES.map((text, i) =>
i === CALLED_INDEX ? (
<span
key={i}
className={[styles.ln, styles.isCalled].join(" ")}
tabIndex={0}
aria-describedby={tipId}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
>
{text}
</span>
) : (
<span key={i} className={styles.ln}>
{text}
</span>
)
)}
</code>
</pre>
</div>
</div>
<div className={styles.tooltip} id={tipId} role="tooltip">
{tooltipText}
</div>
<span className={styles.lnhCursor} aria-hidden="true" />
</div>
);
}03 · 行番号と指し示す行 — 計測 累積面積 14.751% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
長いコードの一行だけを説明するチュートリアル向きです。
.lines { counter-reset: ln; display: block; }
.ln {
position: relative;
display: block;
box-sizing: border-box;
padding-left: 24px;
color: $syn-txt;
white-space: pre;
border-radius: $r-xs;
}
.ln::before {
counter-increment: ln;
content: counter(ln);
position: absolute;
left: 0;
top: 0;
width: 16px;
text-align: right;
color: $syn-dim;
user-select: none;
}
04追加行と削除行を分けて見る diff
何が見えるか
追加の行は緑、削除の行は赤の帯を左端に持ち、記号の部分は別の span なので本文のコードだけがコピーされます。変更のない区間は点3つの一行に畳まれ、押すと aria-expanded も切り替わって開きます。

<main class="stage">
<div class="cd is-demo" id="cd">
<div class="cd__pane">
<div class="cd__head">
<span class="cd__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M80 64a24 24 0 1 1-24-24a24 24 0 0 1 24 24m120 104a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M112 152a8 8 0 0 0-8 8v28.69L66.34 151a8 8 0 0 1-2.34-5.63V95a32 32 0 1 0-16 0v50.38a23.85 23.85 0 0 0 7 17L92.69 200H64a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8M40 64a16 16 0 1 1 16 16a16 16 0 0 1-16-16m168 97v-50.37a23.85 23.85 0 0 0-7-17L163.31 56H192a8 8 0 0 0 0-16h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0V67.31L189.66 105a8 8 0 0 1 2.34 5.66V161a32 32 0 1 0 16 0m-8 47a16 16 0 1 1 16-16a16 16 0 0 1-16 16"/></g></svg></span>
<span class="cd__file">config.css</span>
</div>
<span class="cd__cursor" aria-hidden="true"></span>
<div class="cd__body">
<div class="cd__line cd__line--top">
<span class="cd__sign" aria-hidden="true"> </span><span class="cd__src"><span class="k">.card</span> <span class="p">{</span></span>
</div>
<button class="cd__fold" id="cd-fold" type="button" aria-expanded="false" aria-controls="cd-hidden" aria-label="隠れた行を開く">
<span class="cd__sign" aria-hidden="true"> </span><span class="cd__src cd__dots">···</span>
</button>
<div class="cd__fold-body" id="cd-hidden">
<div class="cd__fold-in">
<div class="cd__line">
<span class="cd__sign" aria-hidden="true"> </span><span class="cd__src"> <span class="k">display</span><span class="p">:</span> <span class="s">flex</span><span class="p">;</span></span>
</div>
<div class="cd__line">
<span class="cd__sign" aria-hidden="true"> </span><span class="cd__src"> <span class="k">align-items</span><span class="p">:</span> <span class="s">center</span><span class="p">;</span></span>
</div>
</div>
</div>
<div class="cd__line cd__line--del cd__line--del1">
<span class="cd__sign">-</span><span class="cd__src"> <span class="k">padding</span><span class="p">:</span> <span class="n">12px 16px</span><span class="p">;</span></span>
</div>
<div class="cd__line cd__line--del cd__line--del2">
<span class="cd__sign">-</span><span class="cd__src"> <span class="k">border-radius</span><span class="p">:</span> <span class="n">4px</span><span class="p">;</span></span>
</div>
<div class="cd__line cd__line--add cd__line--add1">
<span class="cd__sign">+</span><span class="cd__src"> <span class="k">padding</span><span class="p">:</span> <span class="n">16px 20px</span><span class="p">;</span></span>
</div>
<div class="cd__line cd__line--add cd__line--add2">
<span class="cd__sign">+</span><span class="cd__src"> <span class="k">border-radius</span><span class="p">:</span> <span class="n">12px</span><span class="p">;</span></span>
</div>
<div class="cd__line cd__line--top">
<span class="cd__sign" aria-hidden="true"> </span><span class="cd__src"><span class="p">}</span></span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-paper);
// 구문 색 — 어두운 패널
$syn-key: $stage-orange;
$syn-str: $stage-yellow;
$syn-num: $subject-sky;
$syn-dim: rgba($subject-cream, .72);
$syn-txt: $subject-cream;
.cd {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cd__pane {
position: relative;
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
color: $syn-txt;
}
// 포인터가 접힘 줄 쪽으로 다가갔다가 돌아오는 작은 신호 — 실제 커서를 그리지 않고
// 옅은 점 하나로 시선의 이동만 암시한다 (면적은 작아 대표 프레임 선정에 영향 없음)
.cd__cursor {
position: absolute;
inset-inline-end: $sp-2;
top: 34px;
width: 6px;
height: 6px;
border-radius: 50%;
background: $subject-sky;
box-shadow: 0 0 0 4px rgba($subject-sky, .18);
opacity: 0;
pointer-events: none;
}
.cd__head {
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
background: rgba(255, 247, 230, .08);
}
.cd__ic { display: inline-flex; flex: 0 0 auto; color: $subject-sky; }
.cd__ic svg { display: block; }
.cd__file {
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 16px;
font-weight: 700;
color: $syn-txt;
}
.cd__body {
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.cd__line {
display: flex;
align-items: flex-start;
}
.cd__sign {
flex: 0 0 auto;
width: 12px;
color: $syn-dim;
user-select: none;
}
.cd__src { flex: 1 1 auto; min-width: 0; white-space: pre; }
.cd__src .k { color: $syn-key; }
.cd__src .s { color: $syn-str; }
.cd__src .n { color: $syn-num; }
.cd__src .p { color: $syn-dim; }
// 더한 줄 · 뺀 줄 — 왼쪽 띠 + 그 색의 옅은 배경
.cd__line--add {
border-inline-start: 2px solid $color;
background: rgba($color, .16);
}
.cd__line--add .cd__sign { color: $color; font-weight: 700; }
.cd__line--del {
border-inline-start: 2px solid $error;
background: rgba($error, .18);
}
.cd__line--del .cd__sign { color: $error-soft; font-weight: 700; }
// 옅은 민트·붉은 배경이 깔린 줄은 무대(먹)보다 밝다 — 그 위의 주황 키워드는 4.02:1 로 모자란다.
// ⑤(어두운 면 위 파란 글자 → $subject-blue-light)와 같은 방식으로, 토큰을 섞어 한 단계 밝힌 주황을 쓴다.
.cd__line--add .cd__src .k,
.cd__line--del .cd__src .k { color: mix($stage-orange, $subject-cream, 70%); }
.cd__line--top .cd__src { color: $syn-dim; }
// 왼쪽 띠 자체가 자라는 연출용 레이어 — 실제 테두리 위에 덧대는 게 아니라
// 테두리는 늘 실재(정적 상태에서도 보인다)하고, 이 안쪽 살짝 밝은 켜짐만 스케일로 차오른다
.cd__line--add,
.cd__line--del { position: relative; }
.cd__line--add::before,
.cd__line--del::before {
content: "";
position: absolute;
inset-inline-start: -2px;
inset-block: 0;
width: 2px;
transform: scaleY(1);
transform-origin: top center; // 규칙8: diff 띠 위에서 차오름
}
.cd__line--add::before { background: $color; }
.cd__line--del::before { background: $error; }
// ── 접힌 중간 줄 ───────────────────────────────────────
.cd__fold {
display: flex;
align-items: flex-start;
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
cursor: pointer;
}
.cd__fold:focus-visible { outline: 2px solid $subject-sky; outline-offset: 1px; }
.cd__dots { color: $syn-dim; letter-spacing: 2px; }
.cd__fold-body {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows $duration $easing;
}
.cd.is-open .cd__fold-body { grid-template-rows: 1fr; }
.cd__fold-in { min-height: 0; }
// ── 자동 루프 (2s) ─────────────────────────────────────
// 0~14% 정지 · 16%(≈14~30%) 접힘 줄이 부드럽게 펴짐(0fr→1fr) · 20~40% 추가/삭제 띠가 시차를 두고
// 차오름 · 44~72% 정지 · 82~86% 다시 접힘(빠른 컷) · 84~100% 정지. 0%=100% 상태가 같아 이음매가 없다
.cd.is-demo .cd__fold-body {
animation-name: cd-fold;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cd-fold {
0% { grid-template-rows: 0fr; }
14% { grid-template-rows: 0fr; animation-timing-function: $easing; }
30% { grid-template-rows: 1fr; }
82% { grid-template-rows: 1fr; animation-timing-function: steps(1, end); }
86% { grid-template-rows: 0fr; }
100% { grid-template-rows: 0fr; }
}
.cd.is-demo .cd__dots {
animation-name: cd-dots;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cd-dots {
0% { opacity: 1; }
30% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
// 추가·삭제 줄의 띠 — 지연을 다르게 줘 차례로 차오르게 한다. 지연 동안은 backwards 로 0 을 유지해
// 0프레임에 완성 상태가 번쩍이지 않게 한다 (함정 6)
.cd.is-demo .cd__line--del1::before,
.cd.is-demo .cd__line--del2::before,
.cd.is-demo .cd__line--add1::before,
.cd.is-demo .cd__line--add2::before {
animation-name: cd-bar;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cd.is-demo .cd__line--del1::before { animation-delay: 0ms; }
.cd.is-demo .cd__line--del2::before { animation-delay: 70ms; }
.cd.is-demo .cd__line--add1::before { animation-delay: 140ms; }
.cd.is-demo .cd__line--add2::before { animation-delay: 210ms; }
@keyframes cd-bar {
0% { transform: scaleY(0); }
16% { transform: scaleY(0); }
30% { transform: scaleY(1); }
100% { transform: scaleY(1); }
}
.cd.is-demo .cd__cursor {
animation-name: cd-cursor;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cd-cursor {
0% { opacity: 0; transform: translateY(0); }
6% { opacity: .9; transform: translateY(0); }
14% { opacity: .9; transform: translateY(54px); animation-timing-function: steps(1, end); }
18% { opacity: 0; transform: translateY(54px); }
44% { opacity: 0; transform: translateY(54px); }
50% { opacity: .9; transform: translateY(54px); }
72% { opacity: .9; transform: translateY(0); }
80% { opacity: 0; transform: translateY(0); }
100% { opacity: 0; transform: translateY(0); }
}
@media (max-width: 340px) {
.cd__pane { width: 100%; }
.cd__body,
.cd__fold { font-size: 10px; line-height: 16px; padding: $sp-2; }
.cd__body { padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-demo .cd__fold-body,
.cd.is-demo .cd__dots,
.cd.is-demo .cd__cursor,
.cd.is-demo .cd__line--del1::before,
.cd.is-demo .cd__line--del2::before,
.cd.is-demo .cd__line--add1::before,
.cd.is-demo .cd__line--add2::before { animation: none !important; }
.cd.is-demo .cd__cursor { opacity: 0; }
.cd.is-demo .cd__line--del1::before,
.cd.is-demo .cd__line--del2::before,
.cd.is-demo .cd__line--add1::before,
.cd.is-demo .cd__line--add2::before { transform: scaleY(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);
}
.cd {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cd__pane {
position: relative;
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
color: #fff7e6;
}
.cd__cursor {
position: absolute;
inset-inline-end: 8px;
top: 34px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #6ec8ff;
box-shadow: 0 0 0 4px rgba(110, 200, 255, 0.18);
opacity: 0;
pointer-events: none;
}
.cd__head {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 12px;
background: rgba(255, 247, 230, 0.08);
}
.cd__ic {
display: inline-flex;
flex: 0 0 auto;
color: #6ec8ff;
}
.cd__ic svg {
display: block;
}
.cd__file {
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 16px;
font-weight: 700;
color: #fff7e6;
}
.cd__body {
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.cd__line {
display: flex;
align-items: flex-start;
}
.cd__sign {
flex: 0 0 auto;
width: 12px;
color: rgba(255, 247, 230, 0.72);
user-select: none;
}
.cd__src {
flex: 1 1 auto;
min-width: 0;
white-space: pre;
}
.cd__src .k {
color: #ff4d1f;
}
.cd__src .s {
color: #ffd23f;
}
.cd__src .n {
color: #6ec8ff;
}
.cd__src .p {
color: rgba(255, 247, 230, 0.72);
}
.cd__line--add {
border-inline-start: 2px solid #4cd4a6;
background: rgba(76, 212, 166, 0.16);
}
.cd__line--add .cd__sign {
color: #4cd4a6;
font-weight: 700;
}
.cd__line--del {
border-inline-start: 2px solid #c2361a;
background: rgba(194, 54, 26, 0.18);
}
.cd__line--del .cd__sign {
color: #ffe0d8;
font-weight: 700;
}
.cd__line--add .cd__src .k,
.cd__line--del .cd__src .k {
color: rgb(100%, 50.1960784314%, 35.568627451%);
}
.cd__line--top .cd__src {
color: rgba(255, 247, 230, 0.72);
}
.cd__line--add,
.cd__line--del {
position: relative;
}
.cd__line--add::before,
.cd__line--del::before {
content: "";
position: absolute;
inset-inline-start: -2px;
inset-block: 0;
width: 2px;
transform: scaleY(1);
transform-origin: top center;
}
.cd__line--add::before {
background: #4cd4a6;
}
.cd__line--del::before {
background: #c2361a;
}
.cd__fold {
display: flex;
align-items: flex-start;
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
cursor: pointer;
}
.cd__fold:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 1px;
}
.cd__dots {
color: rgba(255, 247, 230, 0.72);
letter-spacing: 2px;
}
.cd__fold-body {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cd.is-open .cd__fold-body {
grid-template-rows: 1fr;
}
.cd__fold-in {
min-height: 0;
}
.cd.is-demo .cd__fold-body {
animation-name: cd-fold;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cd-fold {
0% {
grid-template-rows: 0fr;
}
14% {
grid-template-rows: 0fr;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
30% {
grid-template-rows: 1fr;
}
82% {
grid-template-rows: 1fr;
animation-timing-function: steps(1, end);
}
86% {
grid-template-rows: 0fr;
}
100% {
grid-template-rows: 0fr;
}
}
.cd.is-demo .cd__dots {
animation-name: cd-dots;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cd-dots {
0% {
opacity: 1;
}
30% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.cd.is-demo .cd__line--del1::before,
.cd.is-demo .cd__line--del2::before,
.cd.is-demo .cd__line--add1::before,
.cd.is-demo .cd__line--add2::before {
animation-name: cd-bar;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cd.is-demo .cd__line--del1::before {
animation-delay: 0ms;
}
.cd.is-demo .cd__line--del2::before {
animation-delay: 70ms;
}
.cd.is-demo .cd__line--add1::before {
animation-delay: 140ms;
}
.cd.is-demo .cd__line--add2::before {
animation-delay: 210ms;
}
@keyframes cd-bar {
0% {
transform: scaleY(0);
}
16% {
transform: scaleY(0);
}
30% {
transform: scaleY(1);
}
100% {
transform: scaleY(1);
}
}
.cd.is-demo .cd__cursor {
animation-name: cd-cursor;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cd-cursor {
0% {
opacity: 0;
transform: translateY(0);
}
6% {
opacity: 0.9;
transform: translateY(0);
}
14% {
opacity: 0.9;
transform: translateY(54px);
animation-timing-function: steps(1, end);
}
18% {
opacity: 0;
transform: translateY(54px);
}
44% {
opacity: 0;
transform: translateY(54px);
}
50% {
opacity: 0.9;
transform: translateY(54px);
}
72% {
opacity: 0.9;
transform: translateY(0);
}
80% {
opacity: 0;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(0);
}
}
@media (max-width: 340px) {
.cd__pane {
width: 100%;
}
.cd__body,
.cd__fold {
font-size: 10px;
line-height: 16px;
padding: 8px;
}
.cd__body {
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-demo .cd__fold-body,
.cd.is-demo .cd__dots,
.cd.is-demo .cd__cursor,
.cd.is-demo .cd__line--del1::before,
.cd.is-demo .cd__line--del2::before,
.cd.is-demo .cd__line--add1::before,
.cd.is-demo .cd__line--add2::before {
animation: none !important;
}
.cd.is-demo .cd__cursor {
opacity: 0;
}
.cd.is-demo .cd__line--del1::before,
.cd.is-demo .cd__line--del2::before,
.cd.is-demo .cd__line--add1::before,
.cd.is-demo .cd__line--add2::before {
transform: scaleY(1);
}
}// 실제 동작: 접힌 중간 줄을 펼치고 접는다. 사람이 누르면 자동 루프를 멈춘다
var cd = document.getElementById('cd');
var fold = document.getElementById('cd-fold');
fold.addEventListener('click', function () {
cd.classList.remove('is-demo');
var open = fold.getAttribute('aria-expanded') === 'true';
fold.setAttribute('aria-expanded', String(!open));
fold.setAttribute('aria-label', open ? '隠れた行を開く' : '隠れた行を閉じる');
cd.classList.toggle('is-open', !open);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DiffAddedRemoved.module.scss";
export type DiffAddedRemovedProps = {
fileName?: string;
openLabel?: string;
closeLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function DiffIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path
d="M80 64a24 24 0 1 1-24-24a24 24 0 0 1 24 24m120 104a24 24 0 1 0 24 24a24 24 0 0 0-24-24"
opacity=".2"
/>
<path d="M112 152a8 8 0 0 0-8 8v28.69L66.34 151a8 8 0 0 1-2.34-5.63V95a32 32 0 1 0-16 0v50.38a23.85 23.85 0 0 0 7 17L92.69 200H64a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8M40 64a16 16 0 1 1 16 16a16 16 0 0 1-16-16m168 97v-50.37a23.85 23.85 0 0 0-7-17L163.31 56H192a8 8 0 0 0 0-16h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0V67.31L189.66 105a8 8 0 0 1 2.34 5.66V161a32 32 0 1 0 16 0m-8 47a16 16 0 1 1 16-16a16 16 0 0 1-16 16" />
</g>
</svg>
);
}
export default function DiffAddedRemoved({
fileName = "config.css",
openLabel = "Show hidden lines",
closeLabel = "Hide expanded lines",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: DiffAddedRemovedProps) {
const [open, setOpen] = useState(false);
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.root, live ? styles.isDemo : "", open ? styles.isOpen : ""].join(" ");
function toggle() {
setLive(false);
setOpen(!open);
}
return (
<div className={cls} style={vars}>
<div className={styles.pane}>
<div className={styles.head}>
<span className={styles.ic} aria-hidden="true">
<DiffIcon />
</span>
<span className={styles.file}>{fileName}</span>
</div>
<span className={styles.cursor} aria-hidden="true" />
<div className={styles.body}>
<div className={[styles.line, styles.lineTop].join(" ")}>
<span className={styles.sign} aria-hidden="true">
{" "}
</span>
<span className={styles.src}>
<span className={styles.k}>.card</span> <span className={styles.p}>{"{"}</span>
</span>
</div>
<button
className={styles.fold}
type="button"
aria-expanded={open}
aria-controls="cd-hidden-lines"
aria-label={open ? closeLabel : openLabel}
onClick={toggle}
>
<span className={styles.sign} aria-hidden="true">
{" "}
</span>
<span className={[styles.src, styles.dots].join(" ")}>···</span>
</button>
<div className={styles.foldBody} id="cd-hidden-lines">
<div className={styles.foldIn}>
<div className={styles.line}>
<span className={styles.sign} aria-hidden="true">
{" "}
</span>
<span className={styles.src}>
<span className={styles.k}>display</span>
<span className={styles.p}>:</span> <span className={styles.s}>flex</span>
<span className={styles.p}>;</span>
</span>
</div>
<div className={styles.line}>
<span className={styles.sign} aria-hidden="true">
{" "}
</span>
<span className={styles.src}>
<span className={styles.k}>align-items</span>
<span className={styles.p}>:</span> <span className={styles.s}>center</span>
<span className={styles.p}>;</span>
</span>
</div>
</div>
</div>
<div className={[styles.line, styles.lineDel, styles.lineDel1].join(" ")}>
<span className={styles.sign}>-</span>
<span className={styles.src}>
<span className={styles.k}>padding</span>
<span className={styles.p}>:</span> <span className={styles.n}>12px 16px</span>
<span className={styles.p}>;</span>
</span>
</div>
<div className={[styles.line, styles.lineDel, styles.lineDel2].join(" ")}>
<span className={styles.sign}>-</span>
<span className={styles.src}>
<span className={styles.k}>border-radius</span>
<span className={styles.p}>:</span> <span className={styles.n}>4px</span>
<span className={styles.p}>;</span>
</span>
</div>
<div className={[styles.line, styles.lineAdd, styles.lineAdd1].join(" ")}>
<span className={styles.sign}>+</span>
<span className={styles.src}>
<span className={styles.k}>padding</span>
<span className={styles.p}>:</span> <span className={styles.n}>16px 20px</span>
<span className={styles.p}>;</span>
</span>
</div>
<div className={[styles.line, styles.lineAdd, styles.lineAdd2].join(" ")}>
<span className={styles.sign}>+</span>
<span className={styles.src}>
<span className={styles.k}>border-radius</span>
<span className={styles.p}>:</span> <span className={styles.n}>12px</span>
<span className={styles.p}>;</span>
</span>
</div>
<div className={[styles.line, styles.lineTop].join(" ")}>
<span className={styles.sign} aria-hidden="true">
{" "}
</span>
<span className={styles.src}>
<span className={styles.p}>{"}"}</span>
</span>
</div>
</div>
</div>
</div>
);
}04 · 追加行と削除行を分けて見る diff — 計測 累積面積 28.963% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
設定ファイルの一行だけを変えてもらう移行の文書に合います。
.cd__line--add {
border-inline-start: 2px solid $color;
background: rgba($color, .16);
}
.cd__line--add .cd__sign { color: $color; font-weight: 700; }
.cd__line--del {
border-inline-start: 2px solid $error;
background: rgba($error, .18);
}
.cd__line--del .cd__sign { color: $error; font-weight: 700; }
.cd__line--top .cd__src { color: $syn-dim; }
05長ければ畳んで開くコード窓
画面で起きること
長いファイルは grid-template-rows を 0fr から 1fr へ動かして開くので、高さを先に測って書いておく必要がありません。畳まれている間は下端が薄れる帯が敷かれ、続きがあることが一目で分かります。

<main class="stage">
<div class="ecb is-demo" id="root">
<div class="pane">
<div class="pane__head">
<span class="pane__file">
<svg class="ic" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="m240 128l-48 40H64l-48-40l48-40h128Z" opacity=".2"/><path d="M69.12 94.15L28.5 128l40.62 33.85a8 8 0 1 1-10.24 12.29l-48-40a8 8 0 0 1 0-12.29l48-40a8 8 0 0 1 10.24 12.3m176 27.7l-48-40a8 8 0 1 0-10.24 12.3L227.5 128l-40.62 33.85a8 8 0 1 0 10.24 12.29l48-40a8 8 0 0 0 0-12.29m-82.39-89.37a8 8 0 0 0-10.25 4.79l-64 176a8 8 0 0 0 4.79 10.26A8.1 8.1 0 0 0 96 224a8 8 0 0 0 7.52-5.27l64-176a8 8 0 0 0-4.79-10.25"/></g></svg>
config.js
</span>
</div>
<div class="pane__body">
<pre class="ecb__peek" id="peek"><code><span class="k">export</span> <span class="k">const</span> config = {
theme: <span class="s">"auto"</span>,
duration: <span class="n">220</span>,
easing: <span class="s">"ease-out"</span>,<span class="ecb__more" id="more"><span class="ecb__moreInner"><span class="ecb__line"> radius: <span class="n">12</span>,</span><span class="ecb__line"> shadow: <span class="k">true</span>,</span><span class="ecb__line"> locale: <span class="s">"en-US"</span>,</span><span class="ecb__line"> retry: <span class="n">3</span>,</span><span class="ecb__line">};</span></span></span></code></pre>
<span class="ecb__fade" aria-hidden="true"></span>
</div>
<div class="ecb__toggleRow">
<button class="ecb__toggle" id="toggleBtn" type="button" aria-expanded="false" aria-controls="more">
<span class="ecb__labelWrap">
<span class="ecb__label ecb__label--more" id="labelMore">もっと見る</span>
<span class="ecb__label ecb__label--less" id="labelLess" aria-hidden="true">折りたたむ</span>
</span>
<svg class="ic ecb__caret" viewBox="0 0 256 256" width="12" height="12" aria-hidden="true"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
</button>
</div>
</div>
<span class="ecb__cursor" id="cursor" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-out;
$color: $stage-orange;
@include stage($stage-ink);
.ecb {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
color: $subject-ink;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
height: 28px;
padding: 0 $sp-3;
background: rgba(23, 20, 26, .06);
border-bottom: 1px solid rgba(23, 20, 26, .12);
}
.pane__file {
display: flex;
align-items: center;
gap: $sp-1;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
color: rgba($subject-ink, .72);
}
.pane__file .ic { flex: none; }
.pane__body {
position: relative;
box-sizing: border-box;
padding: $sp-3;
}
.ecb__peek {
margin: 0;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
white-space: pre;
}
.ecb__peek code { font: inherit; }
.k { color: $subject-orange-deep; }
.s { color: $subject-blue-deep; }
.n { color: $error; }
// 접힘/펼침 — grid-template-rows 0fr → 1fr, 고정 높이 없이 실제 콘텐츠 높이로 보간
.ecb__more {
display: grid;
grid-template-rows: 0fr;
}
// 실제 상호작용(비-데모)일 때만 transition — is-demo 루프는 keyframe 애니메이션만 쓴다
// (같은 속성에 transition 과 animation 을 동시에 걸면 강제 currentTime 재생 시 충돌해
// 접힘이 여러 프레임에 걸쳐 보간되지 않고 한 프레임에 훅 꺼지는 문제가 있었다)
.ecb:not(.is-demo) .ecb__more {
transition: grid-template-rows $duration $easing;
}
.ecb.is-open .ecb__more { grid-template-rows: 1fr; }
.ecb__moreInner {
position: relative;
overflow: hidden;
min-height: 0;
}
.ecb__line { display: block; }
// 바닥 페이드 띠 — 접힌 상태에서만 보인다
.ecb__fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 32px;
background: linear-gradient(to bottom, rgba(255, 247, 230, 0), $subject-cream);
pointer-events: none;
}
.ecb.is-open .ecb__fade { opacity: 0; }
.ecb__toggleRow {
box-sizing: border-box;
padding: 0 $sp-3 $sp-3;
}
.ecb__toggle {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: $sp-1;
height: 24px;
padding: 0 $sp-2;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: inherit;
cursor: pointer;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
transition: transform $dur-quick $easing, background $dur-quick $easing;
}
.ecb__toggle:hover { background: rgba(23, 20, 26, .14); }
.ecb__toggle:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 라벨 두 장을 겹쳐 놓고 opacity 만 바꾼다(레이아웃이 안 흔들린다) — pane__copy 의 copy__state 방식과 동일
.ecb__labelWrap {
position: relative;
display: inline-block;
width: 40px;
height: 14px;
}
.ecb__label {
position: absolute;
inset: 0;
white-space: nowrap;
transition: opacity $dur-quick $easing;
}
.ecb__label--more { opacity: 1; }
.ecb__label--less { opacity: 0; }
.ecb.is-open .ecb__label--more { opacity: 0; }
.ecb.is-open .ecb__label--less { opacity: 1; }
.ecb__caret {
flex: none;
transition: transform $dur-quick $easing;
}
.ecb.is-open .ecb__caret { transform: rotate(180deg); }
.ecb__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid $subject-cream;
border-radius: 50%;
background: $subject-ink;
box-shadow: $shadow-press;
pointer-events: none;
opacity: 0;
transform: translate(252px, 16px);
}
.ecb.is-demo .ecb__cursor { opacity: 1; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 함정1 대책: 46-62%(펼침 마무리, 다섯 줄 등장)에서 면적 변화가 가장 크게 남도록,
// 64% 재접힘 직전(62%)에 여분 줄 텍스트를 opacity 0 으로 먼저 비워 둔다.
// 이후 grid-template-rows 1fr→0fr 재접힘은 "이미 빈 영역"을 접는 것이라 픽셀 변화가 작다.
// 접힘 구간도 0.16 (32%, 0.64s) 로 펼침(0.28, 0.56s)보다 넓게 잡아 프레임당 변화율을 낮춘다.
.ecb.is-demo .ecb__more {
animation-name: ecb-grid;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__fade {
animation-name: ecb-fade;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__caret {
animation-name: ecb-caret;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__toggle {
animation-name: ecb-press;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__label--more,
.ecb.is-demo .ecb__label--less {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__label--more { animation-name: ecb-label-more; }
.ecb.is-demo .ecb__label--less { animation-name: ecb-label-less; }
.ecb.is-demo .ecb__moreInner .ecb__line {
opacity: 0;
animation-name: ecb-line;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@for $i from 1 through 5 {
.ecb.is-demo .ecb__moreInner .ecb__line:nth-child(#{$i}) {
animation-delay: ($i - 1) * 0.015s;
}
}
.ecb.is-demo .ecb__cursor {
animation-name: ecb-cursor;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
// fr 값 하나로만 62%→86% 를 맡기면 크롬이 변화를 앞쪽 몇 프레임에 몰아
// 한 프레임에 훅 접히는 것처럼 보인다(실측). 촘촘한 중간 정지점을 직접 박아
// 프레임마다 변화폭을 고르게 나눈다 — 펼침 쪽도 대칭으로 맞춘다.
// (poster 수정: 하이라이트 막을 없애고, 줄은 접힘이 시작되는 86%까지 완전 불투명하게
// 유지한다 — 62~86% 사이 "펼쳐진 빈 상자"가 반투명 유령으로 보이던 문제 대책)
@keyframes ecb-grid {
0%, 14% { grid-template-rows: 0fr; }
20% { grid-template-rows: .12fr; }
26% { grid-template-rows: .32fr; }
32% { grid-template-rows: .55fr; }
38% { grid-template-rows: .8fr; }
44%, 62% { grid-template-rows: 1fr; }
68% { grid-template-rows: .8fr; }
74% { grid-template-rows: .55fr; }
80% { grid-template-rows: .32fr; }
83% { grid-template-rows: .16fr; }
86%, 100% { grid-template-rows: 0fr; }
}
@keyframes ecb-fade {
0%, 14% { opacity: 1; }
30% { opacity: 0; }
88% { opacity: 0; }
96%, 100% { opacity: 1; }
}
// 카레트·라벨은 접힘이 거의 끝나는 84% 에 함께 되돌아온다.
// 63% 에 되돌리면 아직 펼쳐진 창에 「더 보기」가 적혀 화면이 거짓말을 한다 (poster 육안 실측)
@keyframes ecb-caret {
0%, 14% { transform: rotate(0deg); }
40%, 83% { transform: rotate(180deg); }
84%, 100% { transform: rotate(0deg); }
}
@keyframes ecb-press {
0%, 12% { transform: scale(1); }
14% { transform: scale(.94); }
18%, 100% { transform: scale(1); }
}
@keyframes ecb-label-more {
0%, 15% { opacity: 1; }
16%, 83% { opacity: 0; }
84%, 100% { opacity: 1; }
}
@keyframes ecb-label-less {
0%, 15% { opacity: 0; }
16%, 83% { opacity: 1; }
84%, 100% { opacity: 0; }
}
// 다섯 줄 등장 — translateY(6px) → 0, 46~60% 안에 끝내고 접힘이 시작되는 86%까지
// 완전 불투명을 유지한다(62~86% 사이 반투명 유령 방지). 사라짐은 접힘과 함께.
@keyframes ecb-line {
0%, 44% { opacity: 0; transform: translateY(6px); }
60%, 86% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(6px); }
}
// 커서는 86% 안에 처음 자리로 돌아와 그 뒤로는 가만히 있는다(텔레포트 금지).
@keyframes ecb-cursor {
0%, 2% { transform: translate(252px, 16px); }
12%, 74% { transform: translate(48px, 138px); }
86%, 100% { transform: translate(252px, 16px); }
}
@media (max-width: 340px) {
.pane { width: 100%; }
.ecb__peek { font-size: 10px; line-height: 16px; }
.pane__body { padding: $sp-2; }
.ecb__toggleRow { padding: 0 $sp-2 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.ecb.is-demo .ecb__more,
.ecb.is-demo .ecb__fade,
.ecb.is-demo .ecb__caret,
.ecb.is-demo .ecb__toggle,
.ecb.is-demo .ecb__label--more,
.ecb.is-demo .ecb__label--less,
.ecb.is-demo .ecb__moreInner .ecb__line,
.ecb.is-demo .ecb__cursor { animation: none !important; }
.ecb.is-demo .ecb__more { grid-template-rows: 0fr; }
.ecb.is-demo .ecb__cursor { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ecb {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
color: #17141a;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
height: 28px;
padding: 0 12px;
background: rgba(23, 20, 26, 0.06);
border-bottom: 1px solid rgba(23, 20, 26, 0.12);
}
.pane__file {
display: flex;
align-items: center;
gap: 4px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
}
.pane__file .ic {
flex: none;
}
.pane__body {
position: relative;
box-sizing: border-box;
padding: 12px;
}
.ecb__peek {
margin: 0;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
white-space: pre;
}
.ecb__peek code {
font: inherit;
}
.k {
color: #c93000;
}
.s {
color: #0a4fe8;
}
.n {
color: #c2361a;
}
.ecb__more {
display: grid;
grid-template-rows: 0fr;
}
.ecb:not(.is-demo) .ecb__more {
transition: grid-template-rows 600ms ease-out;
}
.ecb.is-open .ecb__more {
grid-template-rows: 1fr;
}
.ecb__moreInner {
position: relative;
overflow: hidden;
min-height: 0;
}
.ecb__line {
display: block;
}
.ecb__fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 32px;
background: linear-gradient(to bottom, rgba(255, 247, 230, 0), #fff7e6);
pointer-events: none;
}
.ecb.is-open .ecb__fade {
opacity: 0;
}
.ecb__toggleRow {
box-sizing: border-box;
padding: 0 12px 12px;
}
.ecb__toggle {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: inherit;
cursor: pointer;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
transition: transform 220ms ease-out, background 220ms ease-out;
}
.ecb__toggle:hover {
background: rgba(23, 20, 26, 0.14);
}
.ecb__toggle:focus-visible {
outline: 2px solid #ff4d1f;
outline-offset: 2px;
}
.ecb__labelWrap {
position: relative;
display: inline-block;
width: 40px;
height: 14px;
}
.ecb__label {
position: absolute;
inset: 0;
white-space: nowrap;
transition: opacity 220ms ease-out;
}
.ecb__label--more {
opacity: 1;
}
.ecb__label--less {
opacity: 0;
}
.ecb.is-open .ecb__label--more {
opacity: 0;
}
.ecb.is-open .ecb__label--less {
opacity: 1;
}
.ecb__caret {
flex: none;
transition: transform 220ms ease-out;
}
.ecb.is-open .ecb__caret {
transform: rotate(180deg);
}
.ecb__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid #fff7e6;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
pointer-events: none;
opacity: 0;
transform: translate(252px, 16px);
}
.ecb.is-demo .ecb__cursor {
opacity: 1;
}
.ecb.is-demo .ecb__more {
animation-name: ecb-grid;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__fade {
animation-name: ecb-fade;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__caret {
animation-name: ecb-caret;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__toggle {
animation-name: ecb-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__label--more,
.ecb.is-demo .ecb__label--less {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ecb.is-demo .ecb__label--more {
animation-name: ecb-label-more;
}
.ecb.is-demo .ecb__label--less {
animation-name: ecb-label-less;
}
.ecb.is-demo .ecb__moreInner .ecb__line {
opacity: 0;
animation-name: ecb-line;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ecb.is-demo .ecb__moreInner .ecb__line:nth-child(1) {
animation-delay: 0s;
}
.ecb.is-demo .ecb__moreInner .ecb__line:nth-child(2) {
animation-delay: 0.015s;
}
.ecb.is-demo .ecb__moreInner .ecb__line:nth-child(3) {
animation-delay: 0.03s;
}
.ecb.is-demo .ecb__moreInner .ecb__line:nth-child(4) {
animation-delay: 0.045s;
}
.ecb.is-demo .ecb__moreInner .ecb__line:nth-child(5) {
animation-delay: 0.06s;
}
.ecb.is-demo .ecb__cursor {
animation-name: ecb-cursor;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes ecb-grid {
0%, 14% {
grid-template-rows: 0fr;
}
20% {
grid-template-rows: 0.12fr;
}
26% {
grid-template-rows: 0.32fr;
}
32% {
grid-template-rows: 0.55fr;
}
38% {
grid-template-rows: 0.8fr;
}
44%, 62% {
grid-template-rows: 1fr;
}
68% {
grid-template-rows: 0.8fr;
}
74% {
grid-template-rows: 0.55fr;
}
80% {
grid-template-rows: 0.32fr;
}
83% {
grid-template-rows: 0.16fr;
}
86%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes ecb-fade {
0%, 14% {
opacity: 1;
}
30% {
opacity: 0;
}
88% {
opacity: 0;
}
96%, 100% {
opacity: 1;
}
}
@keyframes ecb-caret {
0%, 14% {
transform: rotate(0deg);
}
40%, 83% {
transform: rotate(180deg);
}
84%, 100% {
transform: rotate(0deg);
}
}
@keyframes ecb-press {
0%, 12% {
transform: scale(1);
}
14% {
transform: scale(0.94);
}
18%, 100% {
transform: scale(1);
}
}
@keyframes ecb-label-more {
0%, 15% {
opacity: 1;
}
16%, 83% {
opacity: 0;
}
84%, 100% {
opacity: 1;
}
}
@keyframes ecb-label-less {
0%, 15% {
opacity: 0;
}
16%, 83% {
opacity: 1;
}
84%, 100% {
opacity: 0;
}
}
@keyframes ecb-line {
0%, 44% {
opacity: 0;
transform: translateY(6px);
}
60%, 86% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(6px);
}
}
@keyframes ecb-cursor {
0%, 2% {
transform: translate(252px, 16px);
}
12%, 74% {
transform: translate(48px, 138px);
}
86%, 100% {
transform: translate(252px, 16px);
}
}
@media (max-width: 340px) {
.pane {
width: 100%;
}
.ecb__peek {
font-size: 10px;
line-height: 16px;
}
.pane__body {
padding: 8px;
}
.ecb__toggleRow {
padding: 0 8px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ecb.is-demo .ecb__more,
.ecb.is-demo .ecb__fade,
.ecb.is-demo .ecb__caret,
.ecb.is-demo .ecb__toggle,
.ecb.is-demo .ecb__label--more,
.ecb.is-demo .ecb__label--less,
.ecb.is-demo .ecb__moreInner .ecb__line,
.ecb.is-demo .ecb__cursor {
animation: none !important;
}
.ecb.is-demo .ecb__more {
grid-template-rows: 0fr;
}
.ecb.is-demo .ecb__cursor {
opacity: 0;
}
}var root = document.getElementById('root');
var btn = document.getElementById('toggleBtn');
var labelMore = document.getElementById('labelMore');
var labelLess = document.getElementById('labelLess');
function setState(open) {
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
labelMore.setAttribute('aria-hidden', open ? 'true' : 'false');
labelLess.setAttribute('aria-hidden', open ? 'false' : 'true');
root.classList.toggle('is-open', open);
}
btn.addEventListener('click', function () {
// button 요소는 Enter/Space 를 이미 click 으로 보내므로 별도 keydown 처리가 필요 없다
root.classList.remove('is-demo');
setState(btn.getAttribute('aria-expanded') !== 'true');
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ExpandCollapsedBlock.module.scss";
export type ExpandCollapsedBlockProps = {
fileLabel?: string;
moreLabel?: string;
lessLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const CodeIcon = () => (
<svg className={styles.ic} viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="m240 128l-48 40H64l-48-40l48-40h128Z" opacity=".2" />
<path d="M69.12 94.15L28.5 128l40.62 33.85a8 8 0 1 1-10.24 12.29l-48-40a8 8 0 0 1 0-12.29l48-40a8 8 0 0 1 10.24 12.3m176 27.7l-48-40a8 8 0 1 0-10.24 12.3L227.5 128l-40.62 33.85a8 8 0 1 0 10.24 12.29l48-40a8 8 0 0 0 0-12.29m-82.39-89.37a8 8 0 0 0-10.25 4.79l-64 176a8 8 0 0 0 4.79 10.26A8.1 8.1 0 0 0 96 224a8 8 0 0 0 7.52-5.27l64-176a8 8 0 0 0-4.79-10.25" />
</g>
</svg>
);
const CaretIcon = () => (
<svg className={[styles.ic, styles.caret].join(" ")} viewBox="0 0 256 256" width="12" height="12" aria-hidden="true">
<g fill="currentColor">
<path d="m208 96l-80 80l-80-80Z" opacity=".2" />
<path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" />
</g>
</svg>
);
export default function ExpandCollapsedBlock({
fileLabel = "config.js",
moreLabel = "Show more",
lessLabel = "Collapse",
demo = true,
duration = 600,
easing = "ease-out",
color = "#ff4d1f",
}: ExpandCollapsedBlockProps) {
const [live, setLive] = useState(demo);
const [open, setOpen] = useState(false);
function onToggle() {
setLive(false);
setOpen((v) => !v);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={[styles.ecb, live ? styles.isDemo : "", open ? styles.isOpen : ""].join(" ")} style={vars}>
<div className={styles.pane}>
<div className={styles.paneHead}>
<span className={styles.paneFile}>
<CodeIcon />
{fileLabel}
</span>
</div>
<div className={styles.paneBody}>
<pre className={styles.peek}>
<code>
<span className={styles.k}>export</span> <span className={styles.k}>const</span> config = {"{"}
{"\n "}theme: <span className={styles.s}>"auto"</span>,
{"\n "}duration: <span className={styles.n}>220</span>,
{"\n "}easing: <span className={styles.s}>"ease-out"</span>,
<span className={styles.more} id="ecb-react-more">
<span className={styles.moreInner}>
<span className={styles.line}>
{" "}radius: <span className={styles.n}>12</span>,
</span>
<span className={styles.line}>
{" "}shadow: <span className={styles.k}>true</span>,
</span>
<span className={styles.line}>
{" "}locale: <span className={styles.s}>"en-US"</span>,
</span>
<span className={styles.line}>
{" "}retry: <span className={styles.n}>3</span>,
</span>
<span className={styles.line}>{"};"}</span>
</span>
</span>
</code>
</pre>
<span className={styles.fade} aria-hidden="true" />
</div>
<div className={styles.toggleRow}>
<button
className={styles.toggle}
type="button"
aria-expanded={open}
aria-controls="ecb-react-more"
onClick={onToggle}
>
<span className={styles.labelWrap}>
<span className={[styles.label, styles.labelMore].join(" ")} aria-hidden={open}>
{moreLabel}
</span>
<span className={[styles.label, styles.labelLess].join(" ")} aria-hidden={!open}>
{lessLabel}
</span>
</span>
<CaretIcon />
</button>
</div>
</div>
<span className={styles.ecbCursor} aria-hidden="true" />
</div>
);
}05 · 長ければ畳んで開くコード窓 — 計測 累積面積 31.724% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
設定ファイル全体を載せつつ画面は短く保つ文書に使います。
.ecb__more {
display: grid;
grid-template-rows: 0fr;
}
.ecb:not(.is-demo) .ecb__more {
transition: grid-template-rows $duration $easing;
}
.ecb.is-open .ecb__more { grid-template-rows: 1fr; }
.ecb__moreInner {
position: relative;
overflow: hidden;
min-height: 0;
}
.ecb__line { display: block; }
06ファイル名が付いた窓の頭
どんな動きか
窓の頭には3つの点とファイルのアイコン、ファイル名が並び、どのファイルに貼るべきかを一行目より先に伝えます。ファイルが2つ以上あると頭の下がタブになり、選んだタブだけが aria-selected で選択状態になります。

<main class="stage">
<div class="fw is-demo" id="fw">
<div class="fw__head">
<span class="fw__dots" aria-hidden="true">
<span class="fw__dot fw__dot--1"></span>
<span class="fw__dot fw__dot--2"></span>
<span class="fw__dot fw__dot--3"></span>
</span>
<span class="fw__title">
<span class="fw__icon" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M181.66 146.34a8 8 0 0 1 0 11.32l-24 24a8 8 0 0 1-11.32-11.32L164.69 152l-18.35-18.34a8 8 0 0 1 11.32-11.32Zm-72-24a8 8 0 0 0-11.32 0l-24 24a8 8 0 0 0 0 11.32l24 24a8 8 0 0 0 11.32-11.32L91.31 152l18.35-18.34a8 8 0 0 0 0-11.32M216 88v128a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88m-56-8h28.69L160 51.31Zm40 136V96h-48a8 8 0 0 1-8-8V40H56v176z"/></g></svg></span>
<span class="fw__filenameSlot">
<span class="fw__filename" id="fw-title">index.html</span>
<span class="fw__filenameDemo fw__filenameDemo--1" aria-hidden="true">index.html</span>
<span class="fw__filenameDemo fw__filenameDemo--2" aria-hidden="true">style.css</span>
</span>
</span>
</div>
<div class="fw__tabs" role="tablist" aria-labelledby="fw-title">
<span class="fw__ptr" id="fw-ptr" aria-hidden="true"></span>
<button class="fw__tab fw__tab--1" id="fw-tab1" type="button" role="tab" aria-selected="true" aria-controls="fw-panel1" tabindex="0">index.html</button>
<button class="fw__tab fw__tab--2" id="fw-tab2" type="button" role="tab" aria-selected="false" aria-controls="fw-panel2" tabindex="-1">style.css</button>
</div>
<div class="fw__body">
<pre class="fw__panel fw__panel--1 is-active" id="fw-panel1" role="tabpanel" aria-labelledby="fw-tab1" aria-hidden="false"><code><span class="c"><</span><span class="k">section</span> class=<span class="s">"card"</span><span class="c">></span>
<span class="c"><</span><span class="k">h2</span><span class="c">></span>Hello<span class="c"></</span><span class="k">h2</span><span class="c">></span>
<span class="c"><</span><span class="k">p</span><span class="c">></span>Ship it.<span class="c"></</span><span class="k">p</span><span class="c">></span>
<span class="c"><</span><span class="k">button</span><span class="c">></span>Go<span class="c"></</span><span class="k">button</span><span class="c">></span>
<span class="c"></</span><span class="k">section</span><span class="c">></span></code></pre>
<pre class="fw__panel fw__panel--2" id="fw-panel2" role="tabpanel" aria-labelledby="fw-tab2" aria-hidden="true"><code><span class="k">.card</span> <span class="c">{</span>
padding: <span class="n">16px</span><span class="c">;</span>
border-radius: <span class="n">12px</span><span class="c">;</span>
box-shadow: <span class="n">0 2px 8px</span><span class="c">;</span>
<span class="c">}</span></code></pre>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
// 구문 색 — 어두운 패널
$syn-key: $stage-orange;
$syn-str: $stage-yellow;
$syn-num: $subject-sky;
$syn-dim: rgba($subject-cream, .72);
$syn-txt: $subject-cream;
$tab-on-bg: rgba(255, 247, 230, .14);
$tab-off-bg: rgba(255, 247, 230, 0);
$tab-on-fg: $subject-cream;
$tab-off-fg: rgba(255, 247, 230, .5);
.fw {
box-sizing: border-box;
position: relative;
width: 288px;
max-width: 100%;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.fw__head {
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
}
.fw__dots { display: inline-flex; align-items: center; gap: $sp-1; flex: 0 0 auto; }
.fw__dot { width: 8px; height: 8px; border-radius: 50%; }
.fw__dot--1 { background: $error; }
.fw__dot--2 { background: $stage-yellow; }
.fw__dot--3 { background: $subject-mint; }
.fw__title { display: inline-flex; align-items: center; gap: $sp-1; min-width: 0; color: rgba(255, 247, 230, .7); }
.fw__icon { display: inline-flex; flex: 0 0 auto; }
.fw__icon svg { display: block; }
// 머리 파일 이름 자리 — 실제(.fw__filename, JS 가 textContent 를 바꾼다)와
// 데모 전용 두 겹(.fw__filenameDemo)을 겹쳐 둔다. 데모 루프에서는 탭 컷과 같은
// 18%/84% 에 steps(1,end) 로 교체(크로스페이드 금지), 사람이 클릭하면 is-demo 가
// 빠지고 .fw__filename 만 보이며 그 textContent 가 갈아 끼워진다.
.fw__filenameSlot { position: relative; display: inline-block; min-width: 0; }
.fw__filename,
.fw__filenameDemo {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.fw__filenameDemo {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.fw.is-demo .fw__filename { opacity: 0; }
.fw__tabs {
position: relative;
display: flex;
align-items: flex-end;
gap: $sp-1;
padding: 0 $sp-3;
background: rgba(0, 0, 0, .22);
}
.fw__ptr {
position: absolute;
left: 44px;
top: 4px;
// 다른 항목과 같은 포인터 링 — 속이 찬 파란 점은 화면의 UI 부품처럼 읽힌다 (poster 육안 실측)
width: 12px;
height: 12px;
border-radius: 50%;
background: $stage-ink;
border: 2px solid $subject-cream;
box-shadow: $shadow-press;
pointer-events: none;
opacity: 0;
transform: translate(0, 0) scale(1);
}
.fw__tab {
position: relative;
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-xs;
background: $tab-off-bg;
color: $tab-off-fg;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transform: translateY(0);
}
.fw__tab:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.fw__tab[aria-selected="true"] {
background: $tab-on-bg;
color: $tab-on-fg;
transform: translateY(-2px);
}
.fw__body { position: relative; height: 114px; overflow: hidden; }
.fw__panel {
position: absolute;
inset: 0;
box-sizing: border-box;
margin: 0;
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
color: $syn-txt;
white-space: pre;
opacity: 0;
visibility: hidden;
}
.fw__panel.is-active { opacity: 1; visibility: visible; }
.fw__panel .k { color: $syn-key; }
.fw__panel .s { color: $syn-str; }
.fw__panel .n { color: $syn-num; }
.fw__panel .c { color: $syn-dim; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~16% 포인터가 두 번째 탭으로 · 18% 컷: 탭 전환 + 코드 컷 교체 · 18~30% 새 탭이 붙으며 점 3개가 차례 팝
// 34~66% 포인터 복귀 · 84% 컷: 첫 탭으로 복귀 · 84~100% 정지
.fw.is-demo .fw__ptr {
opacity: 1;
animation-duration: $dur-loop;
animation-iteration-count: infinite;
animation-timing-function: $easing;
animation-name: fw-ptr;
}
.fw.is-demo .fw__tab--1 {
animation-duration: $dur-loop;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
animation-name: fw-tab1;
}
.fw.is-demo .fw__tab--2 {
animation-duration: $dur-loop;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
animation-name: fw-tab2;
}
.fw.is-demo .fw__panel--1 {
animation-duration: $dur-loop;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
animation-name: fw-panel1;
}
.fw.is-demo .fw__panel--2 {
animation-duration: $dur-loop;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
animation-name: fw-panel2;
}
.fw.is-demo .fw__filenameDemo--1 {
animation-duration: $dur-loop;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
animation-name: fw-filename1;
}
.fw.is-demo .fw__filenameDemo--2 {
animation-duration: $dur-loop;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
animation-name: fw-filename2;
}
.fw.is-demo .fw__dot--1 {
animation-duration: $dur-instant;
animation-iteration-count: 1;
animation-timing-function: $ease-pop;
animation-fill-mode: backwards;
animation-delay: 360ms;
animation-name: fw-dot-pop;
}
.fw.is-demo .fw__dot--2 {
animation-duration: $dur-instant;
animation-iteration-count: 1;
animation-timing-function: $ease-pop;
animation-fill-mode: backwards;
animation-delay: 430ms;
animation-name: fw-dot-pop;
}
.fw.is-demo .fw__dot--3 {
animation-duration: $dur-instant;
animation-iteration-count: 1;
animation-timing-function: $ease-pop;
animation-fill-mode: backwards;
animation-delay: 500ms;
animation-name: fw-dot-pop;
}
// 끝 좌표만 수정: 132px(88px 이동) 지점이 켜진 탭 라벨과 겹쳐 글자를 가렸다 →
// 탭 오른쪽 빈 자리(214px)로 옮긴다. 경로(퍼센트·이징)는 그대로.
@keyframes fw-ptr {
0% { transform: translate(0, 0) scale(1); }
16% { transform: translate(170px, 0) scale(1); animation-timing-function: $ease-pop; }
18% { transform: translate(170px, 0) scale(.86); animation-timing-function: $ease-pop; }
22% { transform: translate(170px, 0) scale(1); }
34% { transform: translate(170px, 0) scale(1); animation-timing-function: $easing; }
66% { transform: translate(0, 0) scale(1); }
100% { transform: translate(0, 0) scale(1); }
}
@keyframes fw-tab1 {
0% { background: $tab-on-bg; color: $tab-on-fg; transform: translateY(-2px); }
18% { background: $tab-off-bg; color: $tab-off-fg; transform: translateY(0); }
84% { background: $tab-on-bg; color: $tab-on-fg; transform: translateY(-2px); }
100% { background: $tab-on-bg; color: $tab-on-fg; transform: translateY(-2px); }
}
@keyframes fw-tab2 {
0% { background: $tab-off-bg; color: $tab-off-fg; transform: translateY(0); }
18% { background: $tab-on-bg; color: $tab-on-fg; transform: translateY(-2px); }
84% { background: $tab-off-bg; color: $tab-off-fg; transform: translateY(0); }
100% { background: $tab-off-bg; color: $tab-off-fg; transform: translateY(0); }
}
@keyframes fw-panel1 {
0% { opacity: 1; visibility: visible; }
18% { opacity: 0; visibility: hidden; }
84% { opacity: 1; visibility: visible; }
100% { opacity: 1; visibility: visible; }
}
@keyframes fw-panel2 {
0% { opacity: 0; visibility: hidden; }
18% { opacity: 1; visibility: visible; }
84% { opacity: 0; visibility: hidden; }
100% { opacity: 0; visibility: hidden; }
}
@keyframes fw-filename1 {
0% { opacity: 1; }
18% { opacity: 0; }
84% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes fw-filename2 {
0% { opacity: 0; }
18% { opacity: 1; }
84% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes fw-dot-pop {
0% { transform: scale(1); }
50% { transform: scale(1.18); }
100% { transform: scale(1); }
}
@media (max-width: 340px) {
// 폭은 이미 max-width: 100% 라 289px 언저리를 넘지 않는다(320px 실폭 292px 예산 보호) — 몸통 높이만 줄인다
.fw__body { height: 96px; }
.fw__panel { font-size: 10px; line-height: 16px; padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.fw.is-demo .fw__ptr,
.fw.is-demo .fw__tab--1,
.fw.is-demo .fw__tab--2,
.fw.is-demo .fw__panel--1,
.fw.is-demo .fw__panel--2,
.fw.is-demo .fw__filenameDemo--1,
.fw.is-demo .fw__filenameDemo--2,
.fw.is-demo .fw__dot--1,
.fw.is-demo .fw__dot--2,
.fw.is-demo .fw__dot--3 { animation: none !important; }
.fw.is-demo .fw__ptr { opacity: 0; }
.fw.is-demo .fw__tab--1 { background: $tab-off-bg; color: $tab-off-fg; transform: translateY(0); }
.fw.is-demo .fw__tab--2 { background: $tab-on-bg; color: $tab-on-fg; transform: translateY(-2px); }
.fw.is-demo .fw__panel--1 { opacity: 0; visibility: hidden; }
.fw.is-demo .fw__panel--2 { opacity: 1; visibility: visible; }
.fw.is-demo .fw__filenameDemo--1 { opacity: 0; }
.fw.is-demo .fw__filenameDemo--2 { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fw {
box-sizing: border-box;
position: relative;
width: 288px;
max-width: 100%;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.fw__head {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 12px;
}
.fw__dots {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.fw__dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.fw__dot--1 {
background: #c2361a;
}
.fw__dot--2 {
background: #ffd23f;
}
.fw__dot--3 {
background: #4cd4a6;
}
.fw__title {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
color: rgba(255, 247, 230, 0.7);
}
.fw__icon {
display: inline-flex;
flex: 0 0 auto;
}
.fw__icon svg {
display: block;
}
.fw__filenameSlot {
position: relative;
display: inline-block;
min-width: 0;
}
.fw__filename,
.fw__filenameDemo {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.fw__filenameDemo {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.fw.is-demo .fw__filename {
opacity: 0;
}
.fw__tabs {
position: relative;
display: flex;
align-items: flex-end;
gap: 4px;
padding: 0 12px;
background: rgba(0, 0, 0, 0.22);
}
.fw__ptr {
position: absolute;
left: 44px;
top: 4px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #17141a;
border: 2px solid #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
pointer-events: none;
opacity: 0;
transform: translate(0, 0) scale(1);
}
.fw__tab {
position: relative;
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 12px;
border: 0;
border-radius: 4px;
background: rgba(255, 247, 230, 0);
color: rgba(255, 247, 230, 0.5);
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transform: translateY(0);
}
.fw__tab:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.fw__tab[aria-selected=true] {
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
transform: translateY(-2px);
}
.fw__body {
position: relative;
height: 114px;
overflow: hidden;
}
.fw__panel {
position: absolute;
inset: 0;
box-sizing: border-box;
margin: 0;
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
color: #fff7e6;
white-space: pre;
opacity: 0;
visibility: hidden;
}
.fw__panel.is-active {
opacity: 1;
visibility: visible;
}
.fw__panel .k {
color: #ff4d1f;
}
.fw__panel .s {
color: #ffd23f;
}
.fw__panel .n {
color: #6ec8ff;
}
.fw__panel .c {
color: rgba(255, 247, 230, 0.72);
}
.fw.is-demo .fw__ptr {
opacity: 1;
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-name: fw-ptr;
}
.fw.is-demo .fw__tab--1 {
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
animation-name: fw-tab1;
}
.fw.is-demo .fw__tab--2 {
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
animation-name: fw-tab2;
}
.fw.is-demo .fw__panel--1 {
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
animation-name: fw-panel1;
}
.fw.is-demo .fw__panel--2 {
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
animation-name: fw-panel2;
}
.fw.is-demo .fw__filenameDemo--1 {
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
animation-name: fw-filename1;
}
.fw.is-demo .fw__filenameDemo--2 {
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: steps(1, end);
animation-name: fw-filename2;
}
.fw.is-demo .fw__dot--1 {
animation-duration: 140ms;
animation-iteration-count: 1;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-fill-mode: backwards;
animation-delay: 360ms;
animation-name: fw-dot-pop;
}
.fw.is-demo .fw__dot--2 {
animation-duration: 140ms;
animation-iteration-count: 1;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-fill-mode: backwards;
animation-delay: 430ms;
animation-name: fw-dot-pop;
}
.fw.is-demo .fw__dot--3 {
animation-duration: 140ms;
animation-iteration-count: 1;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-fill-mode: backwards;
animation-delay: 500ms;
animation-name: fw-dot-pop;
}
@keyframes fw-ptr {
0% {
transform: translate(0, 0) scale(1);
}
16% {
transform: translate(170px, 0) scale(1);
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
}
18% {
transform: translate(170px, 0) scale(0.86);
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
}
22% {
transform: translate(170px, 0) scale(1);
}
34% {
transform: translate(170px, 0) scale(1);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
66% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(0, 0) scale(1);
}
}
@keyframes fw-tab1 {
0% {
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
transform: translateY(-2px);
}
18% {
background: rgba(255, 247, 230, 0);
color: rgba(255, 247, 230, 0.5);
transform: translateY(0);
}
84% {
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
transform: translateY(-2px);
}
100% {
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
transform: translateY(-2px);
}
}
@keyframes fw-tab2 {
0% {
background: rgba(255, 247, 230, 0);
color: rgba(255, 247, 230, 0.5);
transform: translateY(0);
}
18% {
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
transform: translateY(-2px);
}
84% {
background: rgba(255, 247, 230, 0);
color: rgba(255, 247, 230, 0.5);
transform: translateY(0);
}
100% {
background: rgba(255, 247, 230, 0);
color: rgba(255, 247, 230, 0.5);
transform: translateY(0);
}
}
@keyframes fw-panel1 {
0% {
opacity: 1;
visibility: visible;
}
18% {
opacity: 0;
visibility: hidden;
}
84% {
opacity: 1;
visibility: visible;
}
100% {
opacity: 1;
visibility: visible;
}
}
@keyframes fw-panel2 {
0% {
opacity: 0;
visibility: hidden;
}
18% {
opacity: 1;
visibility: visible;
}
84% {
opacity: 0;
visibility: hidden;
}
100% {
opacity: 0;
visibility: hidden;
}
}
@keyframes fw-filename1 {
0% {
opacity: 1;
}
18% {
opacity: 0;
}
84% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes fw-filename2 {
0% {
opacity: 0;
}
18% {
opacity: 1;
}
84% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes fw-dot-pop {
0% {
transform: scale(1);
}
50% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.fw__body {
height: 96px;
}
.fw__panel {
font-size: 10px;
line-height: 16px;
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.fw.is-demo .fw__ptr,
.fw.is-demo .fw__tab--1,
.fw.is-demo .fw__tab--2,
.fw.is-demo .fw__panel--1,
.fw.is-demo .fw__panel--2,
.fw.is-demo .fw__filenameDemo--1,
.fw.is-demo .fw__filenameDemo--2,
.fw.is-demo .fw__dot--1,
.fw.is-demo .fw__dot--2,
.fw.is-demo .fw__dot--3 {
animation: none !important;
}
.fw.is-demo .fw__ptr {
opacity: 0;
}
.fw.is-demo .fw__tab--1 {
background: rgba(255, 247, 230, 0);
color: rgba(255, 247, 230, 0.5);
transform: translateY(0);
}
.fw.is-demo .fw__tab--2 {
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
transform: translateY(-2px);
}
.fw.is-demo .fw__panel--1 {
opacity: 0;
visibility: hidden;
}
.fw.is-demo .fw__panel--2 {
opacity: 1;
visibility: visible;
}
.fw.is-demo .fw__filenameDemo--1 {
opacity: 0;
}
.fw.is-demo .fw__filenameDemo--2 {
opacity: 1;
}
}var root = document.getElementById('fw');
var tabs = [document.getElementById('fw-tab1'), document.getElementById('fw-tab2')];
var panels = [document.getElementById('fw-panel1'), document.getElementById('fw-panel2')];
var titleEl = document.getElementById('fw-title');
function fwSelect(i) {
root.classList.remove('is-demo');
// 머리의 파일 이름이 켜진 탭을 따라간다 — 거짓 헤더 방지
titleEl.textContent = tabs[i].textContent;
tabs.forEach(function (t, idx) {
var on = idx === i;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.tabIndex = on ? 0 : -1;
});
panels.forEach(function (p, idx) {
var on = idx === i;
p.classList.toggle('is-active', on);
p.setAttribute('aria-hidden', on ? 'false' : 'true');
});
}
tabs.forEach(function (t, i) {
t.addEventListener('click', function () {
fwSelect(i);
t.focus({ preventScroll: true });
});
t.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
e.preventDefault();
var next = (i + (e.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
fwSelect(next);
tabs[next].focus({ preventScroll: true });
});
});import { useState, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./FilenameWindowHeader.module.scss";
export type FilenameWindowHeaderProps = {
filenames?: [string, string];
tabsLabel?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T | null };
const FileCodeIcon = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M181.66 146.34a8 8 0 0 1 0 11.32l-24 24a8 8 0 0 1-11.32-11.32L164.69 152l-18.35-18.34a8 8 0 0 1 11.32-11.32Zm-72-24a8 8 0 0 0-11.32 0l-24 24a8 8 0 0 0 0 11.32l24 24a8 8 0 0 0 11.32-11.32L91.31 152l18.35-18.34a8 8 0 0 0 0-11.32M216 88v128a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88m-56-8h28.69L160 51.31Zm40 136V96h-48a8 8 0 0 1-8-8V40H56v176z" />
</g>
</svg>
);
const HtmlCode = () => (
<>
<span className={styles.c}><</span><span className={styles.k}>section</span> class=<span className={styles.s}>"card"</span><span className={styles.c}>></span>
{"\n "}<span className={styles.c}><</span><span className={styles.k}>h2</span><span className={styles.c}>></span>Hello<span className={styles.c}></</span><span className={styles.k}>h2</span><span className={styles.c}>></span>
{"\n "}<span className={styles.c}><</span><span className={styles.k}>p</span><span className={styles.c}>></span>Ship it.<span className={styles.c}></</span><span className={styles.k}>p</span><span className={styles.c}>></span>
{"\n "}<span className={styles.c}><</span><span className={styles.k}>button</span><span className={styles.c}>></span>Go<span className={styles.c}></</span><span className={styles.k}>button</span><span className={styles.c}>></span>
{"\n"}<span className={styles.c}></</span><span className={styles.k}>section</span><span className={styles.c}>></span>
</>
);
const CssCode = () => (
<>
<span className={styles.k}>.card</span> <span className={styles.c}>{"{"}</span>
{"\n "}padding: <span className={styles.n}>16px</span><span className={styles.c}>;</span>
{"\n "}border-radius: <span className={styles.n}>12px</span><span className={styles.c}>;</span>
{"\n "}box-shadow: <span className={styles.n}>0 2px 8px</span><span className={styles.c}>;</span>
{"\n"}<span className={styles.c}>{"}"}</span>
</>
);
export default function FilenameWindowHeader({
filenames = ["index.html", "style.css"],
tabsLabel = "Files",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: FilenameWindowHeaderProps) {
const [live, setLive] = useState(demo);
const [active, setActive] = useState(0);
const tab1Ref = useRef<HTMLButtonElement>(null) as Box<HTMLButtonElement>;
const tab2Ref = useRef<HTMLButtonElement>(null) as Box<HTMLButtonElement>;
const tabRefs: Box<HTMLButtonElement>[] = [tab1Ref, tab2Ref];
function select(i: number) {
setLive(false);
setActive(i);
}
function onTabKeyDown(e: KeyEvt, i: number) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const next = (i + (e.key === "ArrowRight" ? 1 : -1) + tabRefs.length) % tabRefs.length;
select(next);
const el = tabRefs[next].current;
if (el) el.focus({ preventScroll: true });
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const rootClass = [styles.root, live ? styles.isDemo : ""].join(" ");
return (
<div className={rootClass} style={vars}>
<div className={styles.head}>
<span className={styles.dots} aria-hidden="true">
<span className={[styles.dot, styles.dot1].join(" ")} />
<span className={[styles.dot, styles.dot2].join(" ")} />
<span className={[styles.dot, styles.dot3].join(" ")} />
</span>
<span className={styles.title}>
<span className={styles.icon} aria-hidden="true"><FileCodeIcon /></span>
<span className={styles.filenameSlot}>
<span className={styles.filename} id="fw-react-title">{filenames[active]}</span>
<span className={[styles.filenameDemo, styles.filenameDemo1].join(" ")} aria-hidden="true">{filenames[0]}</span>
<span className={[styles.filenameDemo, styles.filenameDemo2].join(" ")} aria-hidden="true">{filenames[1]}</span>
</span>
</span>
</div>
<div className={styles.tabs} role="tablist" aria-label={tabsLabel}>
<span className={styles.ptr} aria-hidden="true" />
{filenames.map((name, i) => (
<button
key={name}
ref={tabRefs[i]}
className={[styles.tab, i === 0 ? styles.tab1 : styles.tab2].join(" ")}
type="button"
role="tab"
aria-selected={live ? undefined : active === i}
aria-controls={`fw-react-panel${i}`}
tabIndex={live ? -1 : active === i ? 0 : -1}
onClick={() => select(i)}
onKeyDown={(e: KeyEvt) => onTabKeyDown(e, i)}
>
{name}
</button>
))}
</div>
<div className={styles.body}>
<pre
className={[styles.panel, styles.panel1, !live && active === 0 ? styles.isActive : ""].join(" ")}
id="fw-react-panel0"
role="tabpanel"
aria-hidden={live ? undefined : active !== 0}
>
<code><HtmlCode /></code>
</pre>
<pre
className={[styles.panel, styles.panel2, !live && active === 1 ? styles.isActive : ""].join(" ")}
id="fw-react-panel1"
role="tabpanel"
aria-hidden={live ? undefined : active !== 1}
>
<code><CssCode /></code>
</pre>
</div>
</div>
);
}06 · ファイル名が付いた窓の頭 — 計測 累積面積 5.082% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
複数のファイルを同時に直すインストール案内に向いています。
function fwSelect(i) {
root.classList.remove('is-demo');
titleEl.textContent = tabs[i].textContent;
tabs.forEach(function (t, idx) {
var on = idx === i;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.tabIndex = on ? 0 : -1;
});
panels.forEach(function (p, idx) {
var on = idx === i;
p.classList.toggle('is-active', on);
p.setAttribute('aria-hidden', on ? 'false' : 'true');
});
}
07コマンドが自動で打たれるターミナル
何が見えるか
コマンド1行が steps で1文字ずつ伸び、その先でブロックカーソルが点滅します。打ち終わると出力が2行続けて上がり、貼り付ける前に何が返ってくるかを示します。

<main class="stage">
<div class="term is-demo" id="term" role="button" tabindex="0" aria-label="もう一度再生">
<div class="term__head">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z"/></g></svg>
<span class="term__name">bash</span>
</div>
<div class="term__body">
<div class="term__line">
<span class="term__prompt" aria-hidden="true">$</span>
<span class="term__cmd" id="term-cmd">npm i -D eslint prettier</span><span class="term__cursor" aria-hidden="true"></span>
</div>
<div class="term__out" id="term-out1"><span class="c">added </span><span class="n">2 packages</span><span class="c"> in 1.4s</span></div>
<div class="term__out" id="term-out2"><span class="c">audited </span><span class="n">128 packages</span><span class="c"> in 2.1s</span></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-orange);
// 구문 색 — 어두운 패널
$syn-key: $stage-orange;
$syn-str: $stage-yellow;
$syn-num: $subject-sky;
$syn-dim: rgba($subject-cream, .72);
$syn-txt: $subject-cream;
.term {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
cursor: pointer;
}
.term:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.term__head {
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
color: $syn-dim;
background: rgba(255, 247, 230, .08);
}
.term__name {
font-size: 11px;
font-weight: 700;
color: $syn-txt;
}
.term__body {
box-sizing: border-box;
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
color: $syn-txt;
}
.term__line { white-space: nowrap; }
.term__prompt { color: $syn-key; font-weight: 700; }
.term__cmd {
display: inline-block;
overflow: hidden;
white-space: nowrap;
vertical-align: bottom;
width: 24ch; // 정적 상태 — 명령이 이미 다 쳐진 채로 보인다
}
.term__cursor {
display: inline-block;
width: 7px;
height: 13px;
margin-left: 0;
vertical-align: -2px;
background: $subject-cream;
animation-name: term-blink;
animation-duration: 1s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.term__out {
margin-top: $sp-1;
opacity: 1; // 정적 상태 — 출력이 이미 보인다
transform: translateY(0);
}
.term__out .n { color: $syn-num; }
.term__out .c { color: $syn-dim; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~44% 타이핑(steps 24) · 46% 커서는 별도 요소라 폭이 늘면 저절로 줄 끝에 선다 ·
// 50~64% 출력1 등장 · 66~80% 출력2 등장 · 86% 컷으로 리셋(다음 루프 준비) · 86~100% 정지
.term.is-demo .term__cmd {
animation-name: term-type;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards; // 0% 프레임(폭 0)을 시작 전에도 고정 — 지연이 생겨도 깜빡임 없음
}
.term.is-demo #term-out1 {
animation-name: term-out1;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.term.is-demo #term-out2 {
animation-name: term-out2;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes term-type {
0% { width: 0ch; animation-timing-function: steps(24, end); }
44% { width: 24ch; animation-timing-function: steps(1, end); }
86% { width: 24ch; animation-timing-function: steps(1, end); }
100% { width: 0ch; }
}
@keyframes term-out1 {
0% { opacity: 0; transform: translateY(8px); }
50% { opacity: 0; transform: translateY(8px); animation-timing-function: $easing; }
64% { opacity: 1; transform: translateY(0); animation-timing-function: steps(1, end); }
86% { opacity: 1; transform: translateY(0); animation-timing-function: steps(1, end); }
100% { opacity: 0; transform: translateY(8px); }
}
@keyframes term-out2 {
0% { opacity: 0; transform: translateY(8px); }
66% { opacity: 0; transform: translateY(8px); animation-timing-function: $easing; }
80% { opacity: 1; transform: translateY(0); animation-timing-function: steps(1, end); }
86% { opacity: 1; transform: translateY(0); animation-timing-function: steps(1, end); }
100% { opacity: 0; transform: translateY(8px); }
}
@media (max-width: 340px) {
.term { width: 100%; }
.term__body { font-size: 10px; line-height: 16px; padding: $sp-2; }
.term__cursor { height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.term.is-demo .term__cmd,
.term.is-demo #term-out1,
.term.is-demo #term-out2,
.term__cursor { animation: none !important; }
.term.is-demo .term__cmd { width: 24ch; }
.term.is-demo #term-out1,
.term.is-demo #term-out2 { opacity: 1; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.term {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
cursor: pointer;
}
.term:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.term__head {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 12px;
color: rgba(255, 247, 230, 0.72);
background: rgba(255, 247, 230, 0.08);
}
.term__name {
font-size: 11px;
font-weight: 700;
color: #fff7e6;
}
.term__body {
box-sizing: border-box;
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
color: #fff7e6;
}
.term__line {
white-space: nowrap;
}
.term__prompt {
color: #ff4d1f;
font-weight: 700;
}
.term__cmd {
display: inline-block;
overflow: hidden;
white-space: nowrap;
vertical-align: bottom;
width: 24ch;
}
.term__cursor {
display: inline-block;
width: 7px;
height: 13px;
margin-left: 0;
vertical-align: -2px;
background: #fff7e6;
animation-name: term-blink;
animation-duration: 1s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.term__out {
margin-top: 4px;
opacity: 1;
transform: translateY(0);
}
.term__out .n {
color: #6ec8ff;
}
.term__out .c {
color: rgba(255, 247, 230, 0.72);
}
.term.is-demo .term__cmd {
animation-name: term-type;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.term.is-demo #term-out1 {
animation-name: term-out1;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.term.is-demo #term-out2 {
animation-name: term-out2;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes term-type {
0% {
width: 0ch;
animation-timing-function: steps(24, end);
}
44% {
width: 24ch;
animation-timing-function: steps(1, end);
}
86% {
width: 24ch;
animation-timing-function: steps(1, end);
}
100% {
width: 0ch;
}
}
@keyframes term-out1 {
0% {
opacity: 0;
transform: translateY(8px);
}
50% {
opacity: 0;
transform: translateY(8px);
animation-timing-function: ease-out;
}
64% {
opacity: 1;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
86% {
opacity: 1;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
transform: translateY(8px);
}
}
@keyframes term-out2 {
0% {
opacity: 0;
transform: translateY(8px);
}
66% {
opacity: 0;
transform: translateY(8px);
animation-timing-function: ease-out;
}
80% {
opacity: 1;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
86% {
opacity: 1;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
transform: translateY(8px);
}
}
@media (max-width: 340px) {
.term {
width: 100%;
}
.term__body {
font-size: 10px;
line-height: 16px;
padding: 8px;
}
.term__cursor {
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.term.is-demo .term__cmd,
.term.is-demo #term-out1,
.term.is-demo #term-out2,
.term__cursor {
animation: none !important;
}
.term.is-demo .term__cmd {
width: 24ch;
}
.term.is-demo #term-out1,
.term.is-demo #term-out2 {
opacity: 1;
transform: translateY(0);
}
}// 실제 동작: 사람이 터미널을 누르거나 Enter/Space 로 초점을 주면 실제 setTimeout 으로 다시 타이핑한다 (CSS 루프는 멈춘다)
(function () {
var CMD = 'npm i -D eslint prettier';
var root = document.getElementById('term');
var cmd = document.getElementById('term-cmd');
var out1 = document.getElementById('term-out1');
var out2 = document.getElementById('term-out2');
var timers = [];
function clear() { timers.forEach(function (t) { clearTimeout(t); }); timers = []; }
function show(el, on) { el.style.opacity = on ? '1' : '0'; el.style.transform = on ? 'translateY(0)' : 'translateY(8px)'; }
function play() {
root.classList.remove('is-demo');
clear();
cmd.textContent = '';
cmd.style.width = '0ch';
show(out1, false);
show(out2, false);
var i = 0;
(function tick() {
i += 1;
cmd.textContent = CMD.slice(0, i);
cmd.style.width = i + 'ch';
if (i < CMD.length) {
timers.push(setTimeout(tick, 36));
} else {
timers.push(setTimeout(function () { show(out1, true); }, 240));
timers.push(setTimeout(function () { show(out2, true); }, 520));
}
})();
}
root.addEventListener('click', play);
root.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); }
});
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TerminalTypingCursor.module.scss";
export type TerminalTypingCursorProps = {
cmd?: string;
replayLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T | null };
const CMD = "npm i -D eslint prettier";
function TerminalIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" />
<path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z" />
</g>
</svg>
);
}
export default function TerminalTypingCursor({
cmd = CMD,
replayLabel = "Replay",
demo = true,
duration = 2000,
easing = "ease-out",
color = "#6ec8ff",
}: TerminalTypingCursorProps) {
const [live, setLive] = useState(demo);
const [typed, setTyped] = useState(cmd);
const [out1, setOut1] = useState(true);
const [out2, setOut2] = useState(true);
const timers = useRef<number[]>([]) as Box<number[]>;
useEffect(() => {
return () => {
(timers.current || []).forEach((t) => window.clearTimeout(t));
};
}, []);
function clear() {
(timers.current || []).forEach((t) => window.clearTimeout(t));
timers.current = [];
}
function play() {
setLive(false);
clear();
setTyped("");
setOut1(false);
setOut2(false);
let i = 0;
function tick() {
i += 1;
setTyped(cmd.slice(0, i));
if (i < cmd.length) {
const id = window.setTimeout(tick, 36);
(timers.current || (timers.current = [])).push(id);
} else {
const a = window.setTimeout(() => setOut1(true), 240);
const b = window.setTimeout(() => setOut2(true), 520);
timers.current = [...(timers.current || []), a, b];
}
}
tick();
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div
className={[styles.term, live ? styles.isDemo : ""].join(" ")}
style={vars}
role="button"
tabIndex={0}
aria-label={replayLabel}
onClick={play}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
play();
}
}}
>
<div className={styles.head}>
<TerminalIcon />
<span className={styles.name}>bash</span>
</div>
<div className={styles.body}>
<div className={styles.line}>
<span className={styles.prompt} aria-hidden="true">$</span>
<span className={styles.cmd} style={live ? undefined : { width: `${typed.length}ch` }}>{live ? cmd : typed}</span>
<span className={styles.cursor} aria-hidden="true" />
</div>
<div
className={[styles.out, styles.out1].join(" ")}
style={live ? undefined : { opacity: out1 ? 1 : 0, transform: out1 ? "translateY(0)" : "translateY(8px)" }}
>
<span className={styles.c}>added </span>
<span className={styles.n}>2 packages</span>
<span className={styles.c}> in 1.4s</span>
</div>
<div
className={[styles.out, styles.out2].join(" ")}
style={live ? undefined : { opacity: out2 ? 1 : 0, transform: out2 ? "translateY(0)" : "translateY(8px)" }}
>
<span className={styles.c}>audited </span>
<span className={styles.n}>128 packages</span>
<span className={styles.c}> in 2.1s</span>
</div>
</div>
</div>
);
}07 · コマンドが自動で打たれるターミナル — 計測 累積面積 2.603% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
インストールコマンドとその結果を並べる入門文書に使います。
function play() {
root.classList.remove('is-demo');
clear();
cmd.textContent = '';
cmd.style.width = '0ch';
show(out1, false);
show(out2, false);
var i = 0;
(function tick() {
i += 1;
cmd.textContent = CMD.slice(0, i);
cmd.style.width = i + 'ch';
if (i < CMD.length) {
timers.push(setTimeout(tick, 36));
} else {
timers.push(setTimeout(function () { show(out1, true); }, 240));
timers.push(setTimeout(function () { show(out2, true); }, 520));
}
})();
}
08コードと結果を交互に見る窓
画面で起きること
同じ窓がコードと、そのコードが描く結果を交互に見せ、切り替えは1フレームのカットなので2つの層が半透明に重なることはありません。選んだ側は aria-pressed にも反映されるので、どちらのボタンが入っているかが伝わります。

<main class="stage">
<div class="rps is-demo" id="rps">
<div class="rps__head">
<span class="rps__file">badge.css</span>
<div class="rps__tabs">
<button class="rps__tab is-on" id="rps-code-btn" type="button" aria-pressed="true" aria-label="コードを見る">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="m240 128l-48 40H64l-48-40l48-40h128Z" opacity=".2"/><path d="M69.12 94.15L28.5 128l40.62 33.85a8 8 0 1 1-10.24 12.29l-48-40a8 8 0 0 1 0-12.29l48-40a8 8 0 0 1 10.24 12.3m176 27.7l-48-40a8 8 0 1 0-10.24 12.3L227.5 128l-40.62 33.85a8 8 0 1 0 10.24 12.29l48-40a8 8 0 0 0 0-12.29m-82.39-89.37a8 8 0 0 0-10.25 4.79l-64 176a8 8 0 0 0 4.79 10.26A8.1 8.1 0 0 0 96 224a8 8 0 0 0 7.52-5.27l64-176a8 8 0 0 0-4.79-10.25"/></g></svg>
</button>
<button class="rps__tab" id="rps-eye-btn" type="button" aria-pressed="false" aria-label="結果を見る">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32"/></g></svg>
</button>
</div>
</div>
<div class="rps__stage">
<span class="rps__ptr" id="rps-ptr" aria-hidden="true"></span>
<pre class="rps__face rps__face--code" id="rps-code" aria-hidden="false"><code><span class="ln"><span class="k">.badge</span> <span class="c">{</span></span><span class="ln"> <span class="k">border-radius</span><span class="c">:</span> <span class="n">999px</span><span class="c">;</span></span><span class="ln"> <span class="k">background</span><span class="c">:</span> <span class="s">linear-gradient</span><span class="c">(#91a2c9, #bed4df);</span></span><span class="ln"><span class="c">}</span></span></code></pre>
<div class="rps__face rps__face--result" id="rps-result" aria-hidden="true">
<div class="rps__card" id="rps-card">
<span class="rps__badge" id="rps-badge">新着</span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// 구문 색 — 어두운 패널
$syn-key: $stage-orange;
$syn-str: $stage-yellow;
$syn-num: $subject-sky;
$syn-dim: rgba($subject-cream, .72);
$syn-txt: $subject-cream;
.rps {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.rps__head {
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
color: $syn-dim;
background: rgba(255, 247, 230, .08);
}
.rps__file { font-size: 11px; font-weight: 700; color: $syn-txt; }
.rps__tabs { display: flex; align-items: center; gap: $sp-1; margin-left: auto; }
.rps__tab {
display: inline-flex;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: $r-xs;
background: transparent;
color: $syn-dim;
opacity: .55;
cursor: pointer;
}
.rps__tab.is-on { opacity: 1; color: $syn-txt; background: rgba(255, 247, 230, .14); }
.rps__tab:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.rps__stage {
position: relative;
box-sizing: border-box;
height: 132px;
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.rps__ptr {
position: absolute;
top: 6px;
right: 40px;
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255, 247, 230, .7);
box-shadow: 0 0 0 4px rgba(255, 247, 230, .18);
pointer-events: none;
}
.rps__face {
box-sizing: border-box;
position: absolute;
inset: $sp-3;
margin: 0;
padding: 0;
color: $syn-txt;
}
.rps__face--code { opacity: 1; visibility: visible; }
.rps__face--result { opacity: 0; visibility: hidden; }
.rps__face--code .n { color: $syn-num; }
.rps__face--code .s { color: $syn-str; }
.rps__face--code .c { color: $syn-dim; }
.ln { display: block; }
.rps__face--result { display: grid; place-items: center; }
.rps__card {
position: relative;
width: 160px;
height: 84px;
border-radius: $r-card;
background: linear-gradient($subject-blue, $subject-sky);
box-shadow: $shadow-press;
transform: scale(.94);
}
.rps__badge {
position: absolute;
top: -8px;
left: -8px;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $stage-orange;
color: $subject-cream;
font-size: 11px;
font-weight: 700;
opacity: 0;
transform: translateY(6px);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~14% 포인터가 eye 로 · 16% 컷 결과 면 · 18~40% 카드·배지 등장 · 44~68% 포인터 복귀 ·
// 70% 컷 코드 면(즉시 원문 그대로 보임) · 86~100% 정지
.rps.is-demo .rps__ptr {
animation-name: rps-ptr;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-code-btn {
animation-name: rps-tab-code;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-eye-btn {
animation-name: rps-tab-eye;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-code {
animation-name: rps-code;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-result {
animation-name: rps-result;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-card {
animation-name: rps-card;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-badge {
animation-name: rps-badge;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes rps-ptr {
0% { transform: translate(0, 0); }
14% { transform: translate(24px, 0); animation-timing-function: $easing; }
44% { transform: translate(24px, 0); }
68% { transform: translate(0, 0); animation-timing-function: $easing; }
100% { transform: translate(0, 0); }
}
@keyframes rps-tab-code {
0% { opacity: 1; }
16% { opacity: .55; animation-timing-function: steps(1, end); }
70% { opacity: 1; animation-timing-function: steps(1, end); }
100% { opacity: 1; }
}
@keyframes rps-tab-eye {
0% { opacity: .55; }
16% { opacity: 1; animation-timing-function: steps(1, end); }
70% { opacity: .55; animation-timing-function: steps(1, end); }
100% { opacity: .55; }
}
@keyframes rps-code {
0% { opacity: 1; visibility: visible; animation-timing-function: steps(1, end); }
16% { opacity: 0; visibility: hidden; animation-timing-function: steps(1, end); }
70% { opacity: 1; visibility: visible; animation-timing-function: steps(1, end); }
100% { opacity: 1; visibility: visible; }
}
@keyframes rps-result {
0% { opacity: 0; visibility: hidden; animation-timing-function: steps(1, end); }
16% { opacity: 1; visibility: visible; animation-timing-function: steps(1, end); }
70% { opacity: 0; visibility: hidden; animation-timing-function: steps(1, end); }
100% { opacity: 0; visibility: hidden; }
}
@keyframes rps-card {
0% { transform: scale(.94); }
18% { transform: scale(.94); animation-timing-function: $easing; }
40% { transform: scale(1); }
70% { transform: scale(1); }
100% { transform: scale(.94); }
}
@keyframes rps-badge {
0% { opacity: 0; transform: translateY(6px); }
18% { opacity: 0; transform: translateY(6px); animation-timing-function: $easing; }
40% { opacity: 1; transform: translateY(0); }
70% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(6px); }
}
@media (max-width: 340px) {
.rps { width: 100%; }
.rps__stage { font-size: 10px; line-height: 16px; padding: $sp-2; height: 124px; }
.rps__face { inset: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rps.is-demo .rps__ptr,
.rps.is-demo #rps-code-btn,
.rps.is-demo #rps-eye-btn,
.rps.is-demo #rps-code,
.rps.is-demo #rps-result,
.rps.is-demo #rps-card,
.rps.is-demo #rps-badge { animation: none !important; }
.rps.is-demo #rps-code { opacity: 1; visibility: visible; }
.rps.is-demo #rps-result { opacity: 0; visibility: hidden; }
}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);
}
.rps {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.rps__head {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 12px;
color: rgba(255, 247, 230, 0.72);
background: rgba(255, 247, 230, 0.08);
}
.rps__file {
font-size: 11px;
font-weight: 700;
color: #fff7e6;
}
.rps__tabs {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.rps__tab {
display: inline-flex;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: rgba(255, 247, 230, 0.72);
opacity: 0.55;
cursor: pointer;
}
.rps__tab.is-on {
opacity: 1;
color: #fff7e6;
background: rgba(255, 247, 230, 0.14);
}
.rps__tab:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.rps__stage {
position: relative;
box-sizing: border-box;
height: 132px;
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.rps__ptr {
position: absolute;
top: 6px;
right: 40px;
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.7);
box-shadow: 0 0 0 4px rgba(255, 247, 230, 0.18);
pointer-events: none;
}
.rps__face {
box-sizing: border-box;
position: absolute;
inset: 12px;
margin: 0;
padding: 0;
color: #fff7e6;
}
.rps__face--code {
opacity: 1;
visibility: visible;
}
.rps__face--result {
opacity: 0;
visibility: hidden;
}
.rps__face--code .n {
color: #6ec8ff;
}
.rps__face--code .s {
color: #ffd23f;
}
.rps__face--code .c {
color: rgba(255, 247, 230, 0.72);
}
.ln {
display: block;
}
.rps__face--result {
display: grid;
place-items: center;
}
.rps__card {
position: relative;
width: 160px;
height: 84px;
border-radius: 16px;
background: linear-gradient(#2f6df6, #6ec8ff);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: scale(0.94);
}
.rps__badge {
position: absolute;
top: -8px;
left: -8px;
padding: 4px 8px;
border-radius: 999px;
background: #ff4d1f;
color: #fff7e6;
font-size: 11px;
font-weight: 700;
opacity: 0;
transform: translateY(6px);
}
.rps.is-demo .rps__ptr {
animation-name: rps-ptr;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-code-btn {
animation-name: rps-tab-code;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-eye-btn {
animation-name: rps-tab-eye;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-code {
animation-name: rps-code;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-result {
animation-name: rps-result;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-card {
animation-name: rps-card;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-badge {
animation-name: rps-badge;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes rps-ptr {
0% {
transform: translate(0, 0);
}
14% {
transform: translate(24px, 0);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
44% {
transform: translate(24px, 0);
}
68% {
transform: translate(0, 0);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
100% {
transform: translate(0, 0);
}
}
@keyframes rps-tab-code {
0% {
opacity: 1;
}
16% {
opacity: 0.55;
animation-timing-function: steps(1, end);
}
70% {
opacity: 1;
animation-timing-function: steps(1, end);
}
100% {
opacity: 1;
}
}
@keyframes rps-tab-eye {
0% {
opacity: 0.55;
}
16% {
opacity: 1;
animation-timing-function: steps(1, end);
}
70% {
opacity: 0.55;
animation-timing-function: steps(1, end);
}
100% {
opacity: 0.55;
}
}
@keyframes rps-code {
0% {
opacity: 1;
visibility: visible;
animation-timing-function: steps(1, end);
}
16% {
opacity: 0;
visibility: hidden;
animation-timing-function: steps(1, end);
}
70% {
opacity: 1;
visibility: visible;
animation-timing-function: steps(1, end);
}
100% {
opacity: 1;
visibility: visible;
}
}
@keyframes rps-result {
0% {
opacity: 0;
visibility: hidden;
animation-timing-function: steps(1, end);
}
16% {
opacity: 1;
visibility: visible;
animation-timing-function: steps(1, end);
}
70% {
opacity: 0;
visibility: hidden;
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
visibility: hidden;
}
}
@keyframes rps-card {
0% {
transform: scale(0.94);
}
18% {
transform: scale(0.94);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
40% {
transform: scale(1);
}
70% {
transform: scale(1);
}
100% {
transform: scale(0.94);
}
}
@keyframes rps-badge {
0% {
opacity: 0;
transform: translateY(6px);
}
18% {
opacity: 0;
transform: translateY(6px);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
40% {
opacity: 1;
transform: translateY(0);
}
70% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(6px);
}
}
@media (max-width: 340px) {
.rps {
width: 100%;
}
.rps__stage {
font-size: 10px;
line-height: 16px;
padding: 8px;
height: 124px;
}
.rps__face {
inset: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rps.is-demo .rps__ptr,
.rps.is-demo #rps-code-btn,
.rps.is-demo #rps-eye-btn,
.rps.is-demo #rps-code,
.rps.is-demo #rps-result,
.rps.is-demo #rps-card,
.rps.is-demo #rps-badge {
animation: none !important;
}
.rps.is-demo #rps-code {
opacity: 1;
visibility: visible;
}
.rps.is-demo #rps-result {
opacity: 0;
visibility: hidden;
}
}// 실제 동작: 버튼 두 개가 코드 면 / 결과 면을 서로 바꾼다. aria-pressed 가 실제로 갱신된다
(function () {
var root = document.getElementById('rps');
var codeBtn = document.getElementById('rps-code-btn');
var eyeBtn = document.getElementById('rps-eye-btn');
var codeFace = document.getElementById('rps-code');
var resultFace = document.getElementById('rps-result');
function setView(showResult) {
root.classList.remove('is-demo');
codeBtn.setAttribute('aria-pressed', String(!showResult));
eyeBtn.setAttribute('aria-pressed', String(showResult));
codeBtn.classList.toggle('is-on', !showResult);
eyeBtn.classList.toggle('is-on', showResult);
codeFace.style.opacity = showResult ? '0' : '1';
codeFace.style.visibility = showResult ? 'hidden' : 'visible';
resultFace.style.opacity = showResult ? '1' : '0';
resultFace.style.visibility = showResult ? 'visible' : 'hidden';
codeFace.setAttribute('aria-hidden', String(showResult));
resultFace.setAttribute('aria-hidden', String(!showResult));
}
codeBtn.addEventListener('click', function () { setView(false); });
eyeBtn.addEventListener('click', function () { setView(true); });
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ResultPreviewSplit.module.scss";
export type ResultPreviewSplitProps = {
fileName?: string;
codeLabel?: string;
resultLabel?: string;
badgeLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function CodeIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="m240 128l-48 40H64l-48-40l48-40h128Z" opacity=".2" />
<path d="M69.12 94.15L28.5 128l40.62 33.85a8 8 0 1 1-10.24 12.29l-48-40a8 8 0 0 1 0-12.29l48-40a8 8 0 0 1 10.24 12.3m176 27.7l-48-40a8 8 0 1 0-10.24 12.3L227.5 128l-40.62 33.85a8 8 0 1 0 10.24 12.29l48-40a8 8 0 0 0 0-12.29m-82.39-89.37a8 8 0 0 0-10.25 4.79l-64 176a8 8 0 0 0 4.79 10.26A8.1 8.1 0 0 0 96 224a8 8 0 0 0 7.52-5.27l64-176a8 8 0 0 0-4.79-10.25" />
</g>
</svg>
);
}
function EyeIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2" />
<path d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32" />
</g>
</svg>
);
}
export default function ResultPreviewSplit({
fileName = "badge.css",
codeLabel = "View code",
resultLabel = "View result",
badgeLabel = "New",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ResultPreviewSplitProps) {
const [live, setLive] = useState(demo);
const [showResult, setShowResult] = useState(false);
function setView(next: boolean) {
setLive(false);
setShowResult(next);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const codeStyle: CSSProperties | undefined = live
? undefined
: { opacity: showResult ? 0 : 1, visibility: showResult ? "hidden" : "visible" };
const resultStyle: CSSProperties | undefined = live
? undefined
: { opacity: showResult ? 1 : 0, visibility: showResult ? "visible" : "hidden" };
return (
<div className={[styles.rps, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div className={styles.head}>
<span className={styles.file}>{fileName}</span>
<div className={styles.tabs}>
<button
className={[styles.tab, styles.tabCode, !showResult ? styles.isOn : ""].join(" ")}
type="button"
aria-pressed={!showResult}
aria-label={codeLabel}
onClick={() => setView(false)}
>
<CodeIcon />
</button>
<button
className={[styles.tab, styles.tabEye, showResult ? styles.isOn : ""].join(" ")}
type="button"
aria-pressed={showResult}
aria-label={resultLabel}
onClick={() => setView(true)}
>
<EyeIcon />
</button>
</div>
</div>
<div className={styles.stage}>
<span className={styles.ptr} aria-hidden="true" />
<pre className={[styles.face, styles.faceCode].join(" ")} style={codeStyle} aria-hidden={showResult}>
<code>
<span className={styles.ln}><span className={styles.k}>.badge</span> <span className={styles.c}>{"{"}</span></span>
<span className={styles.ln}> <span className={styles.k}>border-radius</span><span className={styles.c}>:</span> <span className={styles.n}>999px</span><span className={styles.c}>;</span></span>
<span className={styles.ln}> <span className={styles.k}>background</span><span className={styles.c}>:</span> <span className={styles.s}>linear-gradient</span><span className={styles.c}>(#26f, #6cf);</span></span>
<span className={styles.ln}><span className={styles.c}>{"}"}</span></span>
</code>
</pre>
<div className={[styles.face, styles.faceResult].join(" ")} style={resultStyle} aria-hidden={!showResult}>
<div className={styles.card}>
<span className={styles.badge}>{badgeLabel}</span>
</div>
</div>
</div>
</div>
);
}08 · コードと結果を交互に見る窓 — 計測 累積面積 11.172% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
結果を見ないとコードが分からないCSS効果の紹介に合います。
function setView(showResult) {
root.classList.remove('is-demo');
codeBtn.setAttribute('aria-pressed', String(!showResult));
eyeBtn.setAttribute('aria-pressed', String(showResult));
codeBtn.classList.toggle('is-on', !showResult);
eyeBtn.classList.toggle('is-on', showResult);
codeFace.style.opacity = showResult ? '0' : '1';
codeFace.style.visibility = showResult ? 'hidden' : 'visible';
resultFace.style.opacity = showResult ? '1' : '0';
resultFace.style.visibility = showResult ? 'visible' : 'hidden';
codeFace.setAttribute('aria-hidden', String(showResult));
resultFace.setAttribute('aria-hidden', String(!showResult));
}
09語を指すと意味が出るインラインコード
どんな動きか
本文中のインラインコードに点線の下線が引かれ、ポインターを乗せるかタブで移動すると、その語が何かを書いた付箋が下に浮かびます。付箋は絶対配置なので親の高さには含まれず、その分の余白をあらかじめ下に空けてあります。

<main class="stage">
<div class="tp is-demo" id="tp">
<div class="tp__head">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg>
<span class="tp__label">tips.md</span>
</div>
<div class="tp__body" id="tp-body">
<p class="tp__line">Round the corners with <button class="tok" id="tp-tok1" type="button" aria-describedby="tp-pop1" aria-label="説明を見る">border-radius</button>.</p>
<p class="tp__line">Lift the card using <button class="tok" id="tp-tok2" type="button" aria-describedby="tp-pop2" aria-label="説明を見る">box-shadow</button>.</p>
<pre class="tp__code"><code><span class="ln"><span class="k">border-radius</span><span class="c">:</span> <span class="n">16px</span><span class="c">;</span></span><span class="ln"><span class="k">box-shadow</span><span class="c">:</span> <span class="s">0 8px 16px</span> <span class="n">rgba(0,0,0,.18)</span><span class="c">;</span></span></code></pre>
<span class="tp__ptr" id="tp-ptr" aria-hidden="true"></span>
<div class="tp__pop" id="tp-pop1" role="tooltip">角を丸くする半径です。</div>
<div class="tp__pop" id="tp-pop2" role="tooltip">浮いている感じを与える影です。</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 구문 색 — 밝은 패널
$syn-key: $subject-orange-deep;
$syn-str: $subject-blue-deep;
$syn-num: $error;
$syn-dim: rgba($subject-ink, .72);
$syn-txt: $subject-ink;
.tp {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
color: $subject-ink;
}
.tp__head {
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
color: rgba(23, 20, 26, .6);
background: rgba(23, 20, 26, .05);
}
.tp__label { font-size: 11px; font-weight: 700; color: $subject-ink; }
.tp__body {
position: relative;
box-sizing: border-box;
padding: $sp-3;
padding-bottom: $sp-10; // 함정 7 — 팝오버는 position:absolute 라 뿌리 높이에 안 잡힌다. 미리 확보
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 18px;
}
.tp__line { margin: 0 0 $sp-1 0; }
.tp__line:last-of-type { margin-bottom: $sp-2; }
.tok {
display: inline;
padding: 0 $sp-1;
border: 0;
border-radius: $r-xs;
background: transparent;
color: $syn-key;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 700;
text-decoration: underline dotted;
text-underline-offset: 3px;
cursor: pointer;
}
.tok.is-on { background: rgba(47, 109, 246, .14); color: $subject-blue-deep; text-decoration: none; }
.tok:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.tp__code {
box-sizing: border-box;
margin: 0;
padding: $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .04);
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
color: $syn-txt;
}
.tp__code .k { color: $syn-key; }
.tp__code .s { color: $syn-str; }
.tp__code .n { color: $syn-num; }
.tp__code .c { color: $syn-dim; }
.ln { display: block; }
.tp__ptr {
position: absolute;
top: 26px;
left: 221px;
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(23, 20, 26, .55);
box-shadow: 0 0 0 4px rgba(23, 20, 26, .12);
pointer-events: none;
}
.tp__pop {
box-sizing: border-box;
position: absolute;
left: $sp-3;
right: $sp-3;
bottom: $sp-3;
padding: $sp-2;
border-radius: $r-sm;
background: $subject-blue;
color: $subject-cream;
font-size: 12px;
line-height: 16px;
font-weight: 700;
box-shadow: $shadow-press;
opacity: 0;
visibility: hidden;
transform: translateY(6px) scale(.96);
}
// 정적 상태 — 사람이 눌러/짚어 열면 바로 보인다 (함정 10)
.tp__pop.is-open { opacity: 1; visibility: visible; transform: translateY(0) scale(1); }
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~14% 포인터가 첫 tok · 16% 팝오버A 등장+tok1 켬 · 34~48% 포인터가 둘째 tok ·
// 50% 컷 A→B 교체(+tok2 켬, tok1 끔) · 52~64% B 정착 · 66~84% 포인터 복귀 + B 서서히 사라짐(tok2 끔) · 86~100% 정지
.tp.is-demo #tp-ptr {
animation-name: tp-ptr;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-tok1 {
animation-name: tp-tok1-on;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-tok2 {
animation-name: tp-tok2-on;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-pop1 {
animation-name: tp-pop1;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-pop2 {
animation-name: tp-pop2;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes tp-ptr {
0% { transform: translate(0, 0); }
14% { transform: translate(0, 0); animation-timing-function: $easing; }
34% { transform: translate(0, 0); animation-timing-function: $easing; }
48% { transform: translate(-46px, 22px); animation-timing-function: $easing; }
66% { transform: translate(-46px, 22px); animation-timing-function: $easing; }
84% { transform: translate(0, 0); }
100% { transform: translate(0, 0); }
}
@keyframes tp-tok1-on {
0% { background: transparent; color: $syn-key; }
16% { background: transparent; color: $syn-key; animation-timing-function: steps(1, end); }
18% { background: rgba(47, 109, 246, .14); color: $subject-blue; animation-timing-function: steps(1, end); }
// 첫 낱말은 둘째 낱말이 켜지는 50% 에 **그 프레임에** 꺼진다.
// 66% 까지 남겨 두면 두 낱말이 동시에 켜져 무엇을 설명 중인지 흐려진다 (poster 육안 실측)
49% { background: rgba(47, 109, 246, .14); color: $subject-blue; animation-timing-function: steps(1, end); }
50% { background: transparent; color: $syn-key; animation-timing-function: steps(1, end); }
100% { background: transparent; color: $syn-key; }
}
@keyframes tp-tok2-on {
0% { background: transparent; color: $syn-key; }
49% { background: transparent; color: $syn-key; animation-timing-function: steps(1, end); }
50% { background: rgba(47, 109, 246, .14); color: $subject-blue; animation-timing-function: steps(1, end); }
83% { background: rgba(47, 109, 246, .14); color: $subject-blue; animation-timing-function: steps(1, end); }
84% { background: transparent; color: $syn-key; }
100% { background: transparent; color: $syn-key; }
}
// 쪽지 두 장은 **같은 자리**에 겹쳐 있다. A→B 교체는 글자만 바뀌므로 변화 면적이 작고,
// 등장(18%)과 퇴장이 훨씬 크다. 그래서 등장은 한 프레임 컷으로 크게 만들고,
// 퇴장은 76~88% 에 걸쳐 아래로 **미끄러져 잘려 나가게** 해 프레임당 변화를 잘게 쪼갠다.
// (opacity 로 빼면 반투명 유령 프레임이 생기고, 컷으로 빼면 그 한 장이 poster 가 된다)
@keyframes tp-pop1 {
0% { opacity: 0; visibility: hidden; transform: translateY(44px); animation-timing-function: steps(1, end); }
17% { opacity: 0; visibility: hidden; transform: translateY(44px); animation-timing-function: steps(1, end); }
18% { opacity: 1; visibility: visible; transform: translateY(0); animation-timing-function: steps(1, end); }
49% { opacity: 1; visibility: visible; transform: translateY(0); animation-timing-function: steps(1, end); }
50% { opacity: 0; visibility: hidden; transform: translateY(44px); animation-timing-function: steps(1, end); }
100% { opacity: 0; visibility: hidden; transform: translateY(44px); }
}
@keyframes tp-pop2 {
0% { opacity: 0; visibility: hidden; transform: translateY(44px); animation-timing-function: steps(1, end); }
49% { opacity: 0; visibility: hidden; transform: translateY(44px); animation-timing-function: steps(1, end); }
50% { opacity: 1; visibility: visible; transform: translateY(0); animation-timing-function: steps(1, end); }
76% { opacity: 1; visibility: visible; transform: translateY(0); animation-timing-function: $easing; }
88% { opacity: 1; visibility: visible; transform: translateY(44px); animation-timing-function: steps(1, end); }
100% { opacity: 0; visibility: hidden; transform: translateY(44px); }
}
@media (max-width: 340px) {
.tp { width: 100%; }
.tp__body { font-size: 11px; line-height: 16px; padding: $sp-2; padding-bottom: $sp-10; }
.tp__code { font-size: 10px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.tp.is-demo #tp-ptr,
.tp.is-demo #tp-tok1,
.tp.is-demo #tp-tok2,
.tp.is-demo #tp-pop1,
.tp.is-demo #tp-pop2 { animation: none !important; }
.tp.is-demo #tp-pop1,
.tp.is-demo #tp-pop2 { opacity: 0; visibility: hidden; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tp {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
color: #17141a;
}
.tp__head {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 12px;
color: rgba(23, 20, 26, 0.6);
background: rgba(23, 20, 26, 0.05);
}
.tp__label {
font-size: 11px;
font-weight: 700;
color: #17141a;
}
.tp__body {
position: relative;
box-sizing: border-box;
padding: 12px;
padding-bottom: 40px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 18px;
}
.tp__line {
margin: 0 0 4px 0;
}
.tp__line:last-of-type {
margin-bottom: 8px;
}
.tok {
display: inline;
padding: 0 4px;
border: 0;
border-radius: 4px;
background: transparent;
color: #c93000;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 700;
text-decoration: underline dotted;
text-underline-offset: 3px;
cursor: pointer;
}
.tok.is-on {
background: rgba(47, 109, 246, 0.14);
color: #0a4fe8;
text-decoration: none;
}
.tok:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.tp__code {
box-sizing: border-box;
margin: 0;
padding: 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.04);
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
color: #17141a;
}
.tp__code .k {
color: #c93000;
}
.tp__code .s {
color: #0a4fe8;
}
.tp__code .n {
color: #c2361a;
}
.tp__code .c {
color: rgba(23, 20, 26, 0.72);
}
.ln {
display: block;
}
.tp__ptr {
position: absolute;
top: 26px;
left: 221px;
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.55);
box-shadow: 0 0 0 4px rgba(23, 20, 26, 0.12);
pointer-events: none;
}
.tp__pop {
box-sizing: border-box;
position: absolute;
left: 12px;
right: 12px;
bottom: 12px;
padding: 8px;
border-radius: 8px;
background: #2f6df6;
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 700;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
opacity: 0;
visibility: hidden;
transform: translateY(6px) scale(0.96);
}
.tp__pop.is-open {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.tp.is-demo #tp-ptr {
animation-name: tp-ptr;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-tok1 {
animation-name: tp-tok1-on;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-tok2 {
animation-name: tp-tok2-on;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-pop1 {
animation-name: tp-pop1;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-pop2 {
animation-name: tp-pop2;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes tp-ptr {
0% {
transform: translate(0, 0);
}
14% {
transform: translate(0, 0);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
34% {
transform: translate(0, 0);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
48% {
transform: translate(-46px, 22px);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
66% {
transform: translate(-46px, 22px);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
84% {
transform: translate(0, 0);
}
100% {
transform: translate(0, 0);
}
}
@keyframes tp-tok1-on {
0% {
background: transparent;
color: #c93000;
}
16% {
background: transparent;
color: #c93000;
animation-timing-function: steps(1, end);
}
18% {
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
animation-timing-function: steps(1, end);
}
49% {
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
animation-timing-function: steps(1, end);
}
50% {
background: transparent;
color: #c93000;
animation-timing-function: steps(1, end);
}
100% {
background: transparent;
color: #c93000;
}
}
@keyframes tp-tok2-on {
0% {
background: transparent;
color: #c93000;
}
49% {
background: transparent;
color: #c93000;
animation-timing-function: steps(1, end);
}
50% {
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
animation-timing-function: steps(1, end);
}
83% {
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
animation-timing-function: steps(1, end);
}
84% {
background: transparent;
color: #c93000;
}
100% {
background: transparent;
color: #c93000;
}
}
@keyframes tp-pop1 {
0% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
animation-timing-function: steps(1, end);
}
17% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
animation-timing-function: steps(1, end);
}
18% {
opacity: 1;
visibility: visible;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
49% {
opacity: 1;
visibility: visible;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
50% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
}
}
@keyframes tp-pop2 {
0% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
animation-timing-function: steps(1, end);
}
49% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
animation-timing-function: steps(1, end);
}
50% {
opacity: 1;
visibility: visible;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
76% {
opacity: 1;
visibility: visible;
transform: translateY(0);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
88% {
opacity: 1;
visibility: visible;
transform: translateY(44px);
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
}
}
@media (max-width: 340px) {
.tp {
width: 100%;
}
.tp__body {
font-size: 11px;
line-height: 16px;
padding: 8px;
padding-bottom: 40px;
}
.tp__code {
font-size: 10px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.tp.is-demo #tp-ptr,
.tp.is-demo #tp-tok1,
.tp.is-demo #tp-tok2,
.tp.is-demo #tp-pop1,
.tp.is-demo #tp-pop2 {
animation: none !important;
}
.tp.is-demo #tp-pop1,
.tp.is-demo #tp-pop2 {
opacity: 0;
visibility: hidden;
}
}// real JS: hover/focus/click shows the matching popover, Esc closes it
(function () {
var root = document.getElementById('tp');
var tok1 = document.getElementById('tp-tok1');
var tok2 = document.getElementById('tp-tok2');
var pop1 = document.getElementById('tp-pop1');
var pop2 = document.getElementById('tp-pop2');
function reset() {
root.classList.remove('is-demo');
tok1.classList.remove('is-on');
tok2.classList.remove('is-on');
pop1.classList.remove('is-open');
pop2.classList.remove('is-open');
}
function open(tok, pop) {
reset();
tok.classList.add('is-on');
pop.classList.add('is-open');
}
tok1.addEventListener('pointerenter', function () { open(tok1, pop1); });
tok1.addEventListener('focus', function () { open(tok1, pop1); });
tok1.addEventListener('click', function () { open(tok1, pop1); });
tok2.addEventListener('pointerenter', function () { open(tok2, pop2); });
tok2.addEventListener('focus', function () { open(tok2, pop2); });
tok2.addEventListener('click', function () { open(tok2, pop2); });
root.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { reset(); }
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./InlineTokenPopover.module.scss";
export type InlineTokenPopoverProps = {
fileName?: string;
describeLabel?: string;
pop1Text?: string;
pop2Text?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
function InfoIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12" />
</g>
</svg>
);
}
export default function InlineTokenPopover({
fileName = "tips.md",
describeLabel = "Show description",
pop1Text = "The radius that rounds off the corners.",
pop2Text = "The shadow that gives a floating feel.",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: InlineTokenPopoverProps) {
const [live, setLive] = useState(demo);
const [openTok, setOpenTok] = useState<0 | 1 | 2>(0);
function open(i: 1 | 2) {
setLive(false);
setOpenTok(i);
}
function reset() {
setOpenTok(0);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const tok1Style: CSSProperties | undefined = live
? undefined
: openTok === 1
? { background: "rgba(47,109,246,.14)", color: "var(--color)" }
: undefined;
const tok2Style: CSSProperties | undefined = live
? undefined
: openTok === 2
? { background: "rgba(47,109,246,.14)", color: "var(--color)" }
: undefined;
const pop1Style: CSSProperties | undefined = live
? undefined
: { opacity: openTok === 1 ? 1 : 0, visibility: openTok === 1 ? "visible" : "hidden", transform: openTok === 1 ? "translateY(0) scale(1)" : "translateY(6px) scale(.96)" };
const pop2Style: CSSProperties | undefined = live
? undefined
: { opacity: openTok === 2 ? 1 : 0, visibility: openTok === 2 ? "visible" : "hidden", transform: openTok === 2 ? "translateY(0) scale(1)" : "translateY(6px) scale(.96)" };
return (
<div
className={[styles.tp, live ? styles.isDemo : ""].join(" ")}
style={vars}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Escape") { e.preventDefault(); reset(); }
}}
>
<div className={styles.head}>
<InfoIcon />
<span className={styles.label}>{fileName}</span>
</div>
<div className={styles.body}>
<p className={styles.line}>
Round the corners with{" "}
<button
className={[styles.tok, styles.tok1, openTok === 1 ? styles.isOn : ""].join(" ")}
type="button"
aria-describedby="tp-pop1"
aria-label={describeLabel}
style={tok1Style}
onPointerEnter={() => open(1)}
onFocus={() => open(1)}
onClick={() => open(1)}
>
border-radius
</button>
.
</p>
<p className={styles.line}>
Lift the card using{" "}
<button
className={[styles.tok, styles.tok2, openTok === 2 ? styles.isOn : ""].join(" ")}
type="button"
aria-describedby="tp-pop2"
aria-label={describeLabel}
style={tok2Style}
onPointerEnter={() => open(2)}
onFocus={() => open(2)}
onClick={() => open(2)}
>
box-shadow
</button>
.
</p>
<pre className={styles.code}><code><span className={styles.ln}><span className={styles.k}>border-radius</span><span className={styles.c}>:</span> <span className={styles.n}>16px</span><span className={styles.c}>;</span></span><span className={styles.ln}><span className={styles.k}>box-shadow</span><span className={styles.c}>:</span> <span className={styles.s}>0 8px 16px</span> <span className={styles.n}>rgba(0,0,0,.18)</span><span className={styles.c}>;</span></span></code></pre>
<span className={styles.ptr} aria-hidden="true" />
<div id="tp-pop1" className={[styles.pop, styles.pop1, openTok === 1 ? styles.isOpen : ""].join(" ")} style={pop1Style} role="tooltip">
{pop1Text}
</div>
<div id="tp-pop2" className={[styles.pop, styles.pop2, openTok === 2 ? styles.isOpen : ""].join(" ")} style={pop2Style} role="tooltip">
{pop2Text}
</div>
</div>
</div>
);
}09 · 語を指すと意味が出るインラインコード — 計測 累積面積 10.227% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
見慣れない属性名が何度も出る技術説明の段落向きです。
.tok {
display: inline;
padding: 0 $sp-1;
border: 0;
border-radius: $r-xs;
background: transparent;
color: $syn-key;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 700;
text-decoration: underline dotted;
text-underline-offset: 3px;
cursor: pointer;
}
.tok.is-on { background: rgba(47, 109, 246, .14); color: $subject-blue; text-decoration: none; }
どこで壊れるか — 落とし穴
最初に壊れたのは02の下の棒でした。この棒の位置と幅を決めるキーフレームの数値は、ブラウザーで各タブの getBoundingClientRect を実際に測って得たものです。測った結果は CSS が 43.2px、SCSS が 49.38px、Tailwind が 67.05px で、左端はそれぞれ 0px・43.2px・92.58px、棒の幅は最初のタブを 1 とした比の scaleX で 1.14 と 1.55 に掛け直してあります。棒の実体は幅1pxの要素で、scaleX で伸ばしているため、実測で端数が出ても滲みやずれが出ません。この実測を飛ばしてタブを均等割りで伸ばすと、棒は SCSS と Tailwind の境目でラベルから外れた場所に止まりました。
次に08で、パネルの中のコード行の右端が静かに短くなっていました。窓が overflow: hidden を持つため、はみ出した部分は警告なく切り取られます。この状態ではページ全体の横スクロールは起こらないので、自動検査のはみ出し測定には引っ掛からず、poster の静止画を目で確かめて初めて気づきました。折り返されるのではなく行ごとに切れるので、一見すると短い例示にしか見えないのが厄介なところです。切り詰めた後の映像は24フレームのうち16フレームで動き、7つのアニメーション規則だけで構成されています。
05では、窓がまだ開いているのにボタンのラベルが先に「もっと見る」へ戻る瞬間がありました。展開が終わる前の63パーセント地点で戻すと画面が嘘をつき、開いたままのコードの上に「もっと見る」と書かれたボタンが載ります。ラベルと矢印の復帰は折りたたみがほぼ終わる84パーセントまで遅らせて、画面の言葉と実際の状態が一致するように直しました。ラベルは2枚を重ねて透過だけで差し替えるので、文字の幅が違っても行が震えることはありません。
09の付箋は position: absolute なので、開いても親の高さに数えられません。下に余白を取っておかないと、狭い画面で開いた付箋が次の要素に重なります。この分は本文の箱の下余白として最初から確保してあり、幅340px以下の規則に切り替わってもそのまま残ります。9つの部品は一つの圧縮ファイルにまとめてあり、記事の中で動いている素の版と同じ動きの React 版も一緒に入っています。窓の中の文字が言語ごとに伸びる場合に備えるなら言語パックJSONの検証チェックの点検が先に要ります。開くときの合言葉は 8j8vmbbq です。
アクセシビリティ
9つとも prefers-reduced-motion: reduce のときは自動再生を止めて、それぞれが伝えたい状態を静止画で残します。動きを消しても「コピーが済んだこと」「どのタブが選ばれていること」まで消えてしまえば、減らしたのは動きではなく情報になるからです。表の内容は各デモのCSSに書いた reduce 時の規則をそのまま書き起こしたものです。
| 項目 | 動きを減らしたとき | キーボード | 読み上げ |
|---|---|---|---|
| 01 コピーのボタン | 「コピーしました」の表示を残す | タブで入り Enter・Space | aria-live が一度だけ読む |
| 02 言語タブ | 最初のCSSの面だけを残す | 矢印キーでタブ移動 | aria-selected が選択を示す |
| 03 行番号 | 薄い行の強調を残し付箋は出さない | タブで行に乗ると開く | aria-describedby が付箋を結ぶ |
| 04 diff | 色帯を全部点けた静止画 | Enter・Space で開閉 | aria-expanded が開閉を示す |
| 05 畳み込み | 畳んだ最初の状態を残す | Enter・Space で開閉 | aria-expanded とラベルの交代 |
| 06 ファイルの頭 | 2番目のタブが選ばれた静止画 | 矢印キーでタブ移動 | aria-selected とファイル名の書き換え |
| 07 ターミナル | 打ち終わった命令と出力を残し点滅を止める | 窓ごとボタンで Enter・Space | role="button" と aria-label |
| 08 結果の切替 | コードの面だけを残す | 2つのボタンを切替 | aria-pressed が入っている側を示す |
| 09 語の付箋 | 付箋は閉じたまま下線を残す | タブで乗り Escape で閉じる | aria-describedby が付箋を結ぶ |
開閉のほとんどは button 要素で作ってあるので、Tabキーと Enter・Space だけで全項目を操作できます。矢印キーが必要なのは02と06のタブ列だけで、左右で末尾と先頭を行き来できます。設定方法の解説はMDN の prefers-reduced-motion のページにまとまっています。記事に載せる見せ方の部品はコンテンツのカテゴリーに、クリックで応える部品はクリックのカテゴリーに、タブの動きの近親はタブメニューの遷移にまとめてあります。
FAQ
コードブロックに色を付けるにはどうすればいいですか?
この9つは <span class="k"> のような目印を行に振り、3色のCSSで染める方式です。単語の種類が増えるようなら軽い着色のライブラリーを <pre> の中に足しても、窓の枠組みには影響しません。コピーは textContent を渡す仕組みなので、足した着色の目印がコピーに混ざることもありません。見せるだけでなく直す窓まで行くならhtml css エディタ UIが次の段階です。
狭い画面でもそのまま動きますか?
9つとも480×300の枠で動作を確かめ、幅340px以下では文字と行間を一段小さくする規則に切り替わります。01では跳ね返りの強い easing のカーソルが320pxの端を一瞬だけ超えたため、移動には跳ねない easing に替えてあります。全項目、横のはみ出しは測定に出ていません。
コード コピー ボタン が効かない環境はありますか?
navigator.clipboard は https など安全なコンテキストでのみ存在するため、手元のファイルを直接開くと消えています。01はそのときに何もせず静かに進み、見た目の表示だけは切り替わります。確かめるときは簡易サーバーを経由して開くと、コピーまで試せます。









