9 Code Block UI Patterns — No Highlighter Library
A code block UI is the small frame built around a snippet — header, copy button, tabs — that tells a reader what they are looking at before they touch a single
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Copy button that says it copied
- 02 Tabs that swap the same example per language
- 03 Line numbers with a called-out line
- 04 Diff that splits added and removed lines
- 05 Long block that folds until you open it
- 06 Window header that carries the file name
- 07 Terminal that types the command itself
- 08 Pane that flips between code and result
- 09 Inline code that explains a token on hover
None of the nine below need an extra library; every color you see comes from plain classes in the markup, with nothing to import. The order follows how a snippet actually gets used on a page rather than how flashy each pattern looks. First it has to be copyable in one click (01), then show the same example in more than one syntax (02). Once a reader is inside the block, attention narrows to one exact line (03) or to what changed between two versions (04). A block that runs long needs a way to fold away (05), and a page with several files needs a header that says which one is open right now (06). Beyond a static snippet, a command can type and answer itself (07), a result can sit right next to the code that drew it (08), and a single unfamiliar word inside a paragraph can explain itself the moment someone points at it (09). What makes a copy code button ui worth building is not the icon but the moment the reader actually takes the code with them. The neighbor on the embed axis that builds even the play button is Custom Video Player CSS Controls, and the list-shaped one stacking in a sidebar is Activity Feed UI Widgets. Line numbers recompute their gutter width the moment they pass three digits, and when two or more lines are highlighted the deepest tint stays on the topmost one so the eye anchors somewhere. The copy button's checkmark reverts after 1.2 seconds, long enough to read without hiding the code it sits over. Tabs keep their height across languages by reserving the tallest label's baseline, so switching from a short CSS example to a longer JavaScript one never jumps.
01Copy button that says it copied
What you see
Pressing the button in the top-right corner hands the snippet to navigator.clipboard and swaps the label in a single frame, with no fade in between.

<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">Saved to clipboard</span>
<button class="pane__copy" id="copyBtn" type="button" aria-label="Copy code">
<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">Copy</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>Copied</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 = 'Copied';
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 · Copy button that says it copied — Measured area 28.962% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
A hidden aria-live region repeats the new label out loud, so someone using a screen reader hears that the copy went through, too.
btn.addEventListener('click', function () {
root.classList.remove('is-demo'); // a human press stops the auto loop
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(codeText.textContent).catch(function () {});
}
btn.classList.add('is-copied');
live.textContent = 'Copied';
clearTimeout(timer);
timer = setTimeout(function () {
btn.classList.remove('is-copied');
live.textContent = '';
}, 1600);
});
02Tabs that swap the same example per language
How the code snippet ui tabs behave on screen
Three tabs show one example in CSS, SCSS, and Tailwind, and a bar underneath slides to whichever tab is active.

<main class="stage">
<div class="lts is-demo" id="root">
<div class="pane">
<div class="pane__head">
<div class="tabs" role="tablist" aria-label="Select language" 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 · Tabs that swap the same example per language — Measured area 2.667% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
That bar is not a fixed width — it is measured from each tab's own content, so a short label and a long one get different underline widths.
@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); }
}
03Line numbers with a called-out line
What moves
Line numbers here are not typed text; a CSS counter prints them onto a pseudo-element, so a reader who copies the snippet never drags a stray number along with it.

<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">Gap between cards</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 · Line numbers with a called-out line — Measured area 14.751% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Hovering or tabbing to the called-out line brings up a small note explaining why that one line matters.
.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;
}
04Diff that splits added and removed lines
What you see
Added lines carry a green edge and removed lines a red one, and the plus or minus sign sits in its own span so only the real code ever gets copied.

<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="Expand hidden lines">
<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 ? 'Expand hidden lines' : 'Collapse hidden lines');
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 that splits added and removed lines — Measured area 28.963% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
A stretch where nothing changed folds into one row until a reader chooses to open it.
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 ? 'Expand hidden lines' : 'Collapse hidden lines');
cd.classList.toggle('is-open', !open);
});
05Long block that folds until you open it
What happens on screen
A long file opens by moving grid-template-rows from 0fr to 1fr, so there is no fixed pixel height to measure and hard-code ahead of time.

<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">Show more</span>
<span class="ecb__label ecb__label--less" id="labelLess" aria-hidden="true">Collapse</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 · Long block that folds until you open it — Measured area 31.724% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
While it stays folded, a fading band sits over the bottom edge as a hint that more code is waiting below.
.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; }
06Window header that carries the file name
What moves
Three dots, a file icon, and a file name sit in the header, so a reader knows where to paste before reading a single line of the panel below.

<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 · Window header that carries the file name — Measured area 5.082% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
With more than one file, that same header becomes the tab strip, and the file name switches to match whichever tab is open.
function fwSelect(i) {
root.classList.remove('is-demo');
// the header file name follows the active tab — no lying header
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');
});
}
07Terminal that types the command itself
What you see
One command grows a character at a time and a block cursor blinks at the end of it, then two lines of output rise underneath once the typing finishes.

<main class="stage">
<div class="term is-demo" id="term" role="button" tabindex="0" aria-label="Replay">
<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 · Terminal that types the command itself — Measured area 2.603% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The whole window doubles as a button — pressing it, or reaching it with Tab and Enter, replays the same typing for real.
@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; }
}
08Pane that flips between code and result
What happens on screen
One pane swaps between the code and the thing that code draws, and the swap happens in a single frame so the two layers never show through each other.

<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="View code">
<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="View result">
<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">New</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 · Pane that flips between code and result — Measured area 11.172% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The active side also lives in aria-pressed, so it is clear which of the two buttons is on even without seeing the highlight.
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));
}
09Inline code that explains a token on hover
What moves
A word sitting inside a sentence carries a dotted underline, and hovering or tabbing to it pops up a short note explaining what that word means.

<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="Show description">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="Show description">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">The radius that rounds off the corners.</div>
<div class="tp__pop" id="tp-pop2" role="tooltip">The shadow that gives a floating feel.</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 · Inline code that explains a token on hover — Measured area 10.227% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Because the note is positioned absolutely, it never pushes the paragraph below it downward, which means the space for it has to be reserved ahead of time instead.
.tp__body {
position: relative;
box-sizing: border-box;
padding: $sp-3;
padding-bottom: $sp-10; // pitfall 7 — the popover is position:absolute, so it adds nothing to the root height. reserve it up front
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 18px;
}
Where it breaks — the trap
The sliding bar under the language tabs in 02 is not eyeballed. A Playwright measurement of the real tab boxes puts CSS at 43.2px starting at 0, SCSS at 49.38px starting at 43.2px, and Tailwind at 67.05px starting at 92.58px — three different widths because "Tailwind" is simply a longer label than "CSS". Hardcode one bar width for all three tabs and it either overshoots the short label or stops short of the long one; only reading each tab's own getBoundingClientRect at every swap keeps the bar honest.
The result pane in 08 rounds its own corners with overflow: hidden, which is ordinary for a card — until a code line runs long. One line inside that panel is wider than the 288px card, and the rounded-corner clip quietly cuts its right edge off. A page-level scroll check never catches this, because the surrounding document itself never overflows; only opening the still frame and reading the last few characters on that line shows the cut.
The toggle in 05 used to flip its caret and its "Show more" label back partway through the closing animation, while the panel underneath was still visibly open on screen — for a few hundred milliseconds the label was announcing a state the panel had not reached yet. Moving both back at 84% — right as the fold actually finishes, rather than partway through it — closed that gap between what the eye sees and what the label claims.
The note in 09 is position: absolute, so it never counts toward its parent's height — on a narrow phone width, skipping the reserved padding below the sentence would let the note land on top of the code sample it is meant to explain. Preparing for strings that grow per language inside the window wants the checks of i18n JSON Checks first. Every one of these nine sits together in one folder, and the archive holding all nine is opened with the same password, 8j8vmbbq, folded into this sentence rather than boxed off on its own line.
Accessibility (reduced-motion)
prefers-reduced-motion: reduce turns off every autoplay loop across all nine, and what is left standing afterward is a chosen resting state, never a frame frozen mid-motion. Keyboard reach and screen-reader labels run on their own track, independent of whether motion is on, since most of these are native buttons underneath the animation.
| Item | Reduced motion leaves | Keyboard | Screen reader |
|---|---|---|---|
| 01 Copy button | Idle label showing, cursor hidden | Native button, Tab then Enter | aria-live announces the new label |
| 02 Language tabs | CSS tab selected, bar at rest | Arrow Left/Right moves focus and swaps | aria-selected marks the active tab |
| 03 Line highlight | Tooltip and cursor hidden until touched | Tab focuses the called-out line | aria-describedby ties the line to its note |
| 04 Diff fold | Fold open, added/removed bars fully drawn | Native button, Tab then Enter | aria-expanded marks the fold state |
| 05 Collapsed block | Panel collapsed, cursor hidden | Native button, Enter or Space | aria-expanded plus a labeled toggle |
| 06 Filename header | Locks to the second tab and its file name | Arrow Left/Right, roving tabindex | aria-selected and the header text move together |
| 07 Terminal | Cursor stops blinking, command and output stay filled in | role="button", Enter or Space replays it |
aria-label announces "다시 재생" |
| 08 Result split | Code face visible, result face hidden | Two native buttons, Tab then Enter | aria-pressed marks which face is on |
| 09 Token popover | Both notes hidden until touched | Tab reaches each token, Escape closes | aria-describedby ties token to note |
Item 01's clipboard write only runs inside a secure context in the first place, which is one more reason the fallback there is a silent no-op rather than a broken button. For the click-triggered half of this family — buttons and toggles that change state on a tap rather than a hover — the click trigger index collects the rest, and the tabbed pattern in 02 has a longer write-up in the CSS tab menu transition post.
FAQ
Does a code block ui design still need a highlighting library like Prism?
No — every color across these nine comes from a plain span with a class, hand-picked for one fixed snippet. A real highlighting library still earns its keep once the code being shown is arbitrary text a user pasted in, since hand-tagging every keyword does not scale beyond a small set of known examples. Going from showing code to editing it is the next step in Code Editor JavaScript.
Why does the copy button in 01 do nothing on some pages?
navigator.clipboard.writeText only runs inside a secure context, so a page served over plain HTTP gets a rejected promise and nothing else happens. See MDN's Clipboard API page for the exact requirement, which is also why the demo wraps the call in a catch that does nothing rather than showing an error.
Can the tab indicator in 02 just use one fixed width instead of measuring?
Only if every label happened to be the same length — and "CSS", "SCSS", and "Tailwind" are not. The moment labels differ, a fixed-width bar either overshoots the short one or falls short of the long one — exactly the gap the real getBoundingClientRect numbers in the trap section above close. Related copy-and-paste chrome, including the button pattern behind 01, lives in the content category and the button state feedback post.









