코드 블록 UI 9종 — 하이라이터 없이 붙여 쓰는 코드 창
코드 블록은 문서 안에 프로그래밍 언어를 그대로 옮겨 담은 상자이고, 아홉 가지 모두 복사·전환·펼침·타이핑 같은 동작이 실제로 붙어 있는 화면입니다.
01 code-copy-button-state
02 code-language-tab-switch
03 code-line-number-highlight
04 code-diff-added-removed
05 code-expand-collapsed-block
06 code-filename-window-header
07 code-terminal-typing-cursor
08 code-result-preview-split
09 code-inline-token-popover
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 누르면 복사됐다고 알려 주는 버튼
- 02 같은 예제를 언어별로 바꾸는 탭
- 03 줄 번호와 짚어 주는 줄
- 04 더한 줄과 뺀 줄을 나눠 보는 diff
- 05 길면 접었다가 펴는 코드 창
- 06 파일 이름이 붙은 창 머리
- 07 명령이 스스로 쳐지는 터미널
- 08 코드와 결과를 번갈아 보는 창
- 09 낱말을 짚으면 뜻이 뜨는 인라인 코드
복사 버튼(01)은 코드 창에 딱 하나만 붙는 가장 작은 부품이라 먼저 옵니다. 탭(02)과 파일 이름 창 머리(06)는 같은 창 안에서 여러 벌을 오가는 부품이라 그다음이고, 줄 번호(03)와 diff(04)는 코드 자체를 읽는 방식을 바꾸는 부품이라 가운데 놓았습니다. 접고 펴기(05)는 화면 밖으로 나가는 분량을 다루고, 터미널(07)과 코드·결과 전환(08)은 코드가 실행된 뒤를 보여 주는 부품이라 뒤쪽에 두었습니다. 마지막 인라인 팝오버(09)는 코드 창 밖, 본문 문장 속으로 들어간 것이라 가장 나중입니다. 코드 블록 만들기에서 진짜 어려운 자리는 문법 색칠이 아니라, 읽는 사람이 코드를 실제로 가져가는 순간을 어디에 두느냐입니다. 임베드 축에서 재생 단추까지 직접 만드는 이웃은 동영상 플레이어 만들기이고, 사이드바에 쌓이는 목록형은 알림 목록 ui입니다. 줄 번호는 세 자리를 넘어갈 때 폭을 다시 계산하도록 했고, 짚어 주는 줄이 두 줄 이상이면 위쪽 줄에만 색을 진하게 남겼습니다. 복사 버튼의 체크 표시는 1.2초 뒤에 원래 모양으로 돌아오게 정했습니다.
01누르면 복사됐다고 알려 주는 버튼
무엇이 보이나
오른쪽 위 버튼을 누르면 Clipboard 문서의 navigator.clipboard.writeText 가 코드 문자열을 통째로 넘겨받고, 라벨은 트랜지션 없이 한 프레임 컷으로 "복사"에서 "복사됨"으로 바뀝니다.

<main class="stage">
<div class="ccb is-demo" id="root">
<div class="pane">
<div class="pane__head">
<span class="pane__lang">CSS</span>
<div class="pane__actions">
<span class="pane__chip" id="chip" aria-hidden="true">클립보드에 담김</span>
<button class="pane__copy" id="copyBtn" type="button" aria-label="코드 복사">
<span class="copy__state copy__state--idle" id="stIdle">
<svg class="ic" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>
<span id="labelIdle">복사</span>
</span>
<span class="copy__state copy__state--done" id="stDone">
<svg class="ic" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span>복사됨</span>
</span>
</button>
</div>
</div>
<div class="pane__body">
<span class="pane__sel" id="sel" aria-hidden="true"></span>
<pre id="codeText"><code><span class="k">.btn</span> {
<span class="k">display</span>: <span class="s">inline-flex</span>;
<span class="k">align-items</span>: <span class="s">center</span>;
<span class="k">gap</span>: <span class="n">8px</span>;
<span class="k">padding</span>: <span class="n">8px</span> <span class="n">16px</span>;
<span class="k">border-radius</span>: <span class="n">12px</span>;
}</code></pre>
</div>
</div>
<span class="ccb__cursor" id="cursor" aria-hidden="true"></span>
<span class="sr" id="live" role="status" aria-live="polite"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.ccb {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
color: $subject-ink;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
background: rgba(23, 20, 26, .06);
border-bottom: 1px solid rgba(23, 20, 26, .12);
}
.pane__lang {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: .02em;
color: rgba($subject-ink, .72);
text-transform: uppercase;
}
.pane__actions {
display: flex;
align-items: center;
gap: $sp-1;
}
// 칩 — 버튼처럼 안 보이게 배경·알약을 떼고 흐린 글자만 (자리는 opacity·transform 으로만 뜨고 진다)
.pane__chip {
box-sizing: border-box;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
color: rgba(23, 20, 26, .45);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 10px;
font-weight: 700;
opacity: 0;
transform: translateY(8px);
transition: opacity $duration $easing, transform $duration $easing;
}
.pane__copy {
position: relative;
box-sizing: border-box;
width: 76px;
height: 24px;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: inherit;
cursor: pointer;
transition: transform $duration $easing;
}
.pane__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.pane__copy:hover { background: rgba(23, 20, 26, .14); }
.copy__state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-1;
border-radius: $r-pill;
pointer-events: none;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
transition: opacity $duration $easing;
}
.copy__state--idle { opacity: 1; color: $subject-ink; }
.copy__state--done { opacity: 0; background: $color; color: $subject-cream; }
.pane__copy.is-copied .copy__state--idle { opacity: 0; }
.pane__copy.is-copied .copy__state--done { opacity: 1; }
.pane__body {
position: relative;
box-sizing: border-box;
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.pane__body pre, .pane__body code { margin: 0; font: inherit; white-space: pre; }
.k { color: $subject-orange-deep; }
.s { color: $subject-blue-deep; }
.n { color: $error; }
// 전체 선택 하이라이트 — 배경이 아니라 겹친 레이어의 opacity 만 움직인다
.pane__sel {
position: absolute;
inset: $sp-1;
border-radius: $r-xs;
background: rgba($color, .18);
opacity: 0;
pointer-events: none;
}
.ccb__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid $subject-cream;
border-radius: 50%;
background: $subject-ink;
box-shadow: $shadow-press;
pointer-events: none;
opacity: 0;
transform: translate(16px, 128px);
}
.ccb.is-demo .ccb__cursor { opacity: 1; }
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 20% 컷: 복사됨 + 전체 하이라이트 ON(면적 최대) · 86% 컷: 복사로 복귀
.ccb.is-demo .copy__state--idle,
.ccb.is-demo .copy__state--done,
.ccb.is-demo .pane__sel {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ccb.is-demo .copy__state--idle { animation-name: ccb-idle; }
.ccb.is-demo .copy__state--done { animation-name: ccb-done; }
.ccb.is-demo .pane__sel { animation-name: ccb-sel; }
.ccb.is-demo .pane__copy {
animation-name: ccb-press;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ccb.is-demo .pane__chip {
animation-name: ccb-chip;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ccb.is-demo .ccb__cursor {
// 커서 이동만은 오버슈트 없는 이징을 쓴다 — $ease-pop(탄성)은 좌표가 목표치를 넘어 튀어
// 뷰포트 경계(320px 폰 폭)를 순간적으로 넘길 수 있다(실측: 커서 우측 끝 322px)
animation-name: ccb-cursor;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
@keyframes ccb-idle {
0% { opacity: 1; }
20% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes ccb-done {
0% { opacity: 0; }
20% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes ccb-sel {
0% { opacity: 0; }
20% { opacity: 1; }
28% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes ccb-press {
0%, 14% { transform: scale(1); }
18% { transform: scale(.94); }
22%, 100% { transform: scale(1); }
}
@keyframes ccb-chip {
0%, 20% { opacity: 0; transform: translateY(8px); }
22%, 34% { opacity: 1; transform: translateY(0); }
72% { opacity: 1; transform: translateY(0); }
84%, 100% { opacity: 0; transform: translateY(8px); }
}
@keyframes ccb-cursor {
// 쉬는 자리는 버튼(x 200~276, y 2~26) 왼쪽 아래 바깥 8px — 체크 아이콘·글자를 가리지 않는다
0%, 2% { transform: translate(16px, 128px); }
16%, 34% { transform: translate(192px, 34px); }
70%, 100% { transform: translate(16px, 128px); }
}
@media (max-width: 340px) {
.pane { width: 100%; }
.pane__body { font-size: 10px; line-height: 16px; padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.ccb.is-demo .copy__state--idle,
.ccb.is-demo .copy__state--done,
.ccb.is-demo .pane__sel,
.ccb.is-demo .pane__copy,
.ccb.is-demo .pane__chip,
.ccb.is-demo .ccb__cursor { animation: none !important; }
.ccb.is-demo .copy__state--idle { opacity: 0; }
.ccb.is-demo .copy__state--done { opacity: 1; }
.ccb.is-demo .ccb__cursor { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ccb {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
color: #17141a;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 28px;
padding: 0 12px;
background: rgba(23, 20, 26, 0.06);
border-bottom: 1px solid rgba(23, 20, 26, 0.12);
}
.pane__lang {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.pane__actions {
display: flex;
align-items: center;
gap: 4px;
}
.pane__chip {
box-sizing: border-box;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
color: rgba(23, 20, 26, 0.45);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 10px;
font-weight: 700;
opacity: 0;
transform: translateY(8px);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pane__copy {
position: relative;
box-sizing: border-box;
width: 76px;
height: 24px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: inherit;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pane__copy:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pane__copy:hover {
background: rgba(23, 20, 26, 0.14);
}
.copy__state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
border-radius: 999px;
pointer-events: none;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.copy__state--idle {
opacity: 1;
color: #17141a;
}
.copy__state--done {
opacity: 0;
background: #2f6df6;
color: #fff7e6;
}
.pane__copy.is-copied .copy__state--idle {
opacity: 0;
}
.pane__copy.is-copied .copy__state--done {
opacity: 1;
}
.pane__body {
position: relative;
box-sizing: border-box;
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.pane__body pre, .pane__body code {
margin: 0;
font: inherit;
white-space: pre;
}
.k {
color: #c93000;
}
.s {
color: #0a4fe8;
}
.n {
color: #c2361a;
}
.pane__sel {
position: absolute;
inset: 4px;
border-radius: 4px;
background: rgba(47, 109, 246, 0.18);
opacity: 0;
pointer-events: none;
}
.ccb__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid #fff7e6;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
pointer-events: none;
opacity: 0;
transform: translate(16px, 128px);
}
.ccb.is-demo .ccb__cursor {
opacity: 1;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.ccb.is-demo .copy__state--idle,
.ccb.is-demo .copy__state--done,
.ccb.is-demo .pane__sel {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ccb.is-demo .copy__state--idle {
animation-name: ccb-idle;
}
.ccb.is-demo .copy__state--done {
animation-name: ccb-done;
}
.ccb.is-demo .pane__sel {
animation-name: ccb-sel;
}
.ccb.is-demo .pane__copy {
animation-name: ccb-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ccb.is-demo .pane__chip {
animation-name: ccb-chip;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ccb.is-demo .ccb__cursor {
animation-name: ccb-cursor;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes ccb-idle {
0% {
opacity: 1;
}
20% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes ccb-done {
0% {
opacity: 0;
}
20% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ccb-sel {
0% {
opacity: 0;
}
20% {
opacity: 1;
}
28% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ccb-press {
0%, 14% {
transform: scale(1);
}
18% {
transform: scale(0.94);
}
22%, 100% {
transform: scale(1);
}
}
@keyframes ccb-chip {
0%, 20% {
opacity: 0;
transform: translateY(8px);
}
22%, 34% {
opacity: 1;
transform: translateY(0);
}
72% {
opacity: 1;
transform: translateY(0);
}
84%, 100% {
opacity: 0;
transform: translateY(8px);
}
}
@keyframes ccb-cursor {
0%, 2% {
transform: translate(16px, 128px);
}
16%, 34% {
transform: translate(192px, 34px);
}
70%, 100% {
transform: translate(16px, 128px);
}
}
@media (max-width: 340px) {
.pane {
width: 100%;
}
.pane__body {
font-size: 10px;
line-height: 16px;
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ccb.is-demo .copy__state--idle,
.ccb.is-demo .copy__state--done,
.ccb.is-demo .pane__sel,
.ccb.is-demo .pane__copy,
.ccb.is-demo .pane__chip,
.ccb.is-demo .ccb__cursor {
animation: none !important;
}
.ccb.is-demo .copy__state--idle {
opacity: 0;
}
.ccb.is-demo .copy__state--done {
opacity: 1;
}
.ccb.is-demo .ccb__cursor {
opacity: 0;
}
}// 실제 동작: navigator.clipboard 가 없으면 조용히 넘어간다
var root = document.getElementById('root');
var btn = document.getElementById('copyBtn');
var codeText = document.getElementById('codeText');
var live = document.getElementById('live');
var timer = null;
btn.addEventListener('click', function () {
root.classList.remove('is-demo'); // 사람이 누르면 자동 루프를 멈춘다
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(codeText.textContent).catch(function () {});
}
btn.classList.add('is-copied');
live.textContent = '복사됨';
clearTimeout(timer);
timer = setTimeout(function () {
btn.classList.remove('is-copied');
live.textContent = '';
}, 1600);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CopyButtonState.module.scss";
export type CopyButtonStateProps = {
langLabel?: string;
idleLabel?: string;
doneLabel?: string;
chipLabel?: string;
ariaLabel?: string;
code?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const Copy = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M216 40v128h-48V88H88V40Z" opacity=".2" />
<path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" />
</g>
</svg>
);
const Check = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" />
<path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" />
</g>
</svg>
);
const DEFAULT_CODE = `.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 12px;
}`;
export default function CopyButtonState({
langLabel = "CSS",
idleLabel = "Copy",
doneLabel = "Copied",
chipLabel = "Saved to clipboard",
ariaLabel = "Copy code",
code = DEFAULT_CODE,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#0a4fe8",
}: CopyButtonStateProps) {
const [live, setLive] = useState(demo);
const [copied, setCopied] = useState(false);
const timer = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timer.current !== null) window.clearTimeout(timer.current);
};
}, []);
function onClick() {
setLive(false);
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(code).catch(() => {});
}
setCopied(true);
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), 1600);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={[styles.ccb, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div className={styles.pane}>
<div className={styles.paneHead}>
<span className={styles.paneLang}>{langLabel}</span>
<div className={styles.paneActions}>
<span className={styles.paneChip} aria-hidden="true">{chipLabel}</span>
<button
className={[styles.paneCopy, !live && copied ? styles.isCopied : ""].join(" ")}
type="button"
aria-label={ariaLabel}
onClick={onClick}
>
<span className={[styles.copyState, styles.copyStateIdle].join(" ")}>
<Copy />
<span>{idleLabel}</span>
</span>
<span className={[styles.copyState, styles.copyStateDone].join(" ")}>
<Check />
<span>{doneLabel}</span>
</span>
</button>
</div>
</div>
<div className={styles.paneBody}>
<span className={styles.paneSel} aria-hidden="true" />
<pre>
<code>
<span className={styles.k}>.btn</span> {"{"}
{"\n "}
<span className={styles.k}>display</span>: <span className={styles.s}>inline-flex</span>;
{"\n "}
<span className={styles.k}>align-items</span>: <span className={styles.s}>center</span>;
{"\n "}
<span className={styles.k}>gap</span>: <span className={styles.n}>8px</span>;
{"\n "}
<span className={styles.k}>padding</span>: <span className={styles.n}>8px</span> <span className={styles.n}>16px</span>;
{"\n "}
<span className={styles.k}>border-radius</span>: <span className={styles.n}>12px</span>;
{"\n}"}
</code>
</pre>
</div>
</div>
<span className={styles.ccbCursor} aria-hidden="true" />
<span className={styles.sr} role="status" aria-live="polite">{!live && copied ? doneLabel : ""}</span>
</div>
);
}01 · 누르면 복사됐다고 알려 주는 버튼 — 측정 누적면적 28.962% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
같은 문구가 aria-live="polite" 칸에도 함께 적혀 화면을 보지 않는 사람도 복사가 끝난 순간을 알 수 있습니다.
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(codeText.textContent).catch(function () {});
}
btn.classList.add('is-copied');
live.textContent = '복사됨';
clearTimeout(timer);
timer = setTimeout(function () {
btn.classList.remove('is-copied');
live.textContent = '';
}, 1600);
});
02같은 예제를 언어별로 바꾸는 탭
코드 스니펫 탭은 화면에서 어떻게 보이나
CSS·SCSS·Tailwind 세 탭 아래 깔린 막대는 클릭할 때마다 getBoundingClientRect 로 눌린 탭의 폭과 왼쪽 좌표를 다시 재서 translateX·scaleX 값으로 바꿔 넣습니다.

<main class="stage">
<div class="lts is-demo" id="root">
<div class="pane">
<div class="pane__head">
<div class="tabs" role="tablist" aria-label="언어 선택" id="tablist">
<button class="tab" role="tab" id="tabCss" aria-selected="true" aria-controls="panelCss" data-lang="css" type="button">CSS</button>
<button class="tab" role="tab" id="tabScss" aria-selected="false" aria-controls="panelScss" tabindex="-1" data-lang="scss" type="button">SCSS</button>
<button class="tab" role="tab" id="tabTw" aria-selected="false" aria-controls="panelTw" tabindex="-1" data-lang="tailwind" type="button">Tailwind</button>
<span class="tabs__indicator" id="indicator" aria-hidden="true"></span>
</div>
</div>
<div class="pane__body">
<pre class="panel" id="panelCss" role="tabpanel" aria-labelledby="tabCss"><code><span class="k">.btn</span> {
<span class="k">display</span>: <span class="s">flex</span>;
<span class="k">align-items</span>: <span class="s">center</span>;
<span class="k">gap</span>: <span class="n">8px</span>;
<span class="k">padding</span>: <span class="n">8px</span> <span class="n">16px</span>;
}</code></pre>
<pre class="panel" id="panelScss" role="tabpanel" aria-labelledby="tabScss" hidden><code><span class="k">.btn</span> {
<span class="k">display</span>: <span class="s">flex</span>;
<span class="k">gap</span>: <span class="n">8px</span>;
<span class="k">&:hover</span> { <span class="k">opacity</span>: <span class="n">.8</span>; }
}</code></pre>
<pre class="panel" id="panelTw" role="tabpanel" aria-labelledby="tabTw" hidden><code><span class="c"><button class="</span>
<span class="s">flex items-center gap-2</span>
<span class="s">px-4 py-2 rounded-xl</span>
<span class="c">"></span>
Copy
<span class="c"></button></span></code></pre>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
$syn-key: $stage-orange;
$syn-str: $stage-yellow;
$syn-num: $subject-sky;
$syn-dim: rgba($subject-cream, .72);
$syn-txt: $subject-cream;
@include stage($stage-yellow);
.lts {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
color: $subject-cream;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-2;
background: rgba(255, 247, 230, .06);
border-bottom: 1px solid rgba(255, 247, 230, .12);
}
.tabs {
position: relative;
display: flex;
align-items: stretch;
height: 100%;
}
.tab {
flex: 0 0 auto;
box-sizing: border-box;
height: 100%;
padding: 0 $sp-3;
border: 0;
background: transparent;
color: $syn-dim;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: color $duration $easing;
}
.tab:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.tab[aria-selected="true"] { color: $subject-cream; }
.tabs__indicator {
position: absolute;
left: 0;
bottom: 0;
width: 1px;
height: 2px;
border-radius: $r-xs;
background: $color;
transform-origin: left center; // 규칙8: 탭 언더라인 왼쪽 기준
transform: translateX(0) scaleX(1);
transition: transform $duration $easing;
}
.pane__body {
position: relative;
box-sizing: border-box;
height: 132px;
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.panel {
position: absolute;
inset: $sp-3;
margin: 0;
font: inherit;
white-space: pre;
opacity: 1;
}
.panel[hidden] { display: block; opacity: 0; }
.k { color: $syn-key; }
.s { color: $syn-str; }
.n { color: $syn-num; }
.c { color: $syn-dim; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 인디케이터는 부드럽게 미끄러지고, 코드 본문 세 겹은 steps(1,end) 컷으로 갈아 끼운다
.lts.is-demo .tabs__indicator {
animation-name: lts-indicator;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lts.is-demo #tabCss,
.lts.is-demo #tabScss,
.lts.is-demo #tabTw {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lts.is-demo #tabCss { animation-name: lts-tab-css; }
.lts.is-demo #tabScss { animation-name: lts-tab-scss; }
.lts.is-demo #tabTw { animation-name: lts-tab-tw; }
.lts.is-demo #panelCss,
.lts.is-demo #panelScss,
.lts.is-demo #panelTw {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
display: block;
}
.lts.is-demo #panelCss { animation-name: lts-panel-css; }
.lts.is-demo #panelScss { animation-name: lts-panel-scss; }
.lts.is-demo #panelTw { animation-name: lts-panel-tw; }
// 폭·오프셋은 Playwright getBoundingClientRect 실측값(스크래치패드/261_tabmeasure.py):
// CSS 43.2px @0 · SCSS 49.38px @43.2 · Tailwind 67.05px @92.58 (기준폭 = CSS 43.2px)
@keyframes lts-indicator {
0% { transform: translateX(0) scaleX(1); }
30% { transform: translateX(43.2px) scaleX(1.14); }
60% { transform: translateX(92.58px) scaleX(1.55); }
88% { transform: translateX(0) scaleX(1); }
100% { transform: translateX(0) scaleX(1); }
}
@keyframes lts-tab-css {
0% { color: $subject-cream; }
30% { color: $syn-dim; }
88% { color: $subject-cream; }
100% { color: $subject-cream; }
}
@keyframes lts-tab-scss {
0% { color: $syn-dim; }
30% { color: $subject-cream; }
60% { color: $syn-dim; }
100% { color: $syn-dim; }
}
@keyframes lts-tab-tw {
0% { color: $syn-dim; }
60% { color: $subject-cream; }
88% { color: $syn-dim; }
100% { color: $syn-dim; }
}
@keyframes lts-panel-css {
0% { opacity: 1; }
30% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes lts-panel-scss {
0% { opacity: 0; }
30% { opacity: 1; }
60% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes lts-panel-tw {
0% { opacity: 0; }
60% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.pane { width: 100%; }
.pane__body { font-size: 10px; line-height: 16px; padding: $sp-2; height: 118px; }
.panel { inset: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.lts.is-demo .tabs__indicator,
.lts.is-demo #tabCss,
.lts.is-demo #tabScss,
.lts.is-demo #tabTw,
.lts.is-demo #panelCss,
.lts.is-demo #panelScss,
.lts.is-demo #panelTw { animation: none !important; }
.lts.is-demo #panelScss,
.lts.is-demo #panelTw { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lts {
position: relative;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
color: #fff7e6;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 8px;
background: rgba(255, 247, 230, 0.06);
border-bottom: 1px solid rgba(255, 247, 230, 0.12);
}
.tabs {
position: relative;
display: flex;
align-items: stretch;
height: 100%;
}
.tab {
flex: 0 0 auto;
box-sizing: border-box;
height: 100%;
padding: 0 12px;
border: 0;
background: transparent;
color: rgba(255, 247, 230, 0.72);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tab:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.tab[aria-selected=true] {
color: #fff7e6;
}
.tabs__indicator {
position: absolute;
left: 0;
bottom: 0;
width: 1px;
height: 2px;
border-radius: 4px;
background: #2f6df6;
transform-origin: left center;
transform: translateX(0) scaleX(1);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pane__body {
position: relative;
box-sizing: border-box;
height: 132px;
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.panel {
position: absolute;
inset: 12px;
margin: 0;
font: inherit;
white-space: pre;
opacity: 1;
}
.panel[hidden] {
display: block;
opacity: 0;
}
.k {
color: #ff4d1f;
}
.s {
color: #ffd23f;
}
.n {
color: #6ec8ff;
}
.c {
color: rgba(255, 247, 230, 0.72);
}
.lts.is-demo .tabs__indicator {
animation-name: lts-indicator;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lts.is-demo #tabCss,
.lts.is-demo #tabScss,
.lts.is-demo #tabTw {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lts.is-demo #tabCss {
animation-name: lts-tab-css;
}
.lts.is-demo #tabScss {
animation-name: lts-tab-scss;
}
.lts.is-demo #tabTw {
animation-name: lts-tab-tw;
}
.lts.is-demo #panelCss,
.lts.is-demo #panelScss,
.lts.is-demo #panelTw {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
display: block;
}
.lts.is-demo #panelCss {
animation-name: lts-panel-css;
}
.lts.is-demo #panelScss {
animation-name: lts-panel-scss;
}
.lts.is-demo #panelTw {
animation-name: lts-panel-tw;
}
@keyframes lts-indicator {
0% {
transform: translateX(0) scaleX(1);
}
30% {
transform: translateX(43.2px) scaleX(1.14);
}
60% {
transform: translateX(92.58px) scaleX(1.55);
}
88% {
transform: translateX(0) scaleX(1);
}
100% {
transform: translateX(0) scaleX(1);
}
}
@keyframes lts-tab-css {
0% {
color: #fff7e6;
}
30% {
color: rgba(255, 247, 230, 0.72);
}
88% {
color: #fff7e6;
}
100% {
color: #fff7e6;
}
}
@keyframes lts-tab-scss {
0% {
color: rgba(255, 247, 230, 0.72);
}
30% {
color: #fff7e6;
}
60% {
color: rgba(255, 247, 230, 0.72);
}
100% {
color: rgba(255, 247, 230, 0.72);
}
}
@keyframes lts-tab-tw {
0% {
color: rgba(255, 247, 230, 0.72);
}
60% {
color: #fff7e6;
}
88% {
color: rgba(255, 247, 230, 0.72);
}
100% {
color: rgba(255, 247, 230, 0.72);
}
}
@keyframes lts-panel-css {
0% {
opacity: 1;
}
30% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes lts-panel-scss {
0% {
opacity: 0;
}
30% {
opacity: 1;
}
60% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes lts-panel-tw {
0% {
opacity: 0;
}
60% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.pane {
width: 100%;
}
.pane__body {
font-size: 10px;
line-height: 16px;
padding: 8px;
height: 118px;
}
.panel {
inset: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.lts.is-demo .tabs__indicator,
.lts.is-demo #tabCss,
.lts.is-demo #tabScss,
.lts.is-demo #tabTw,
.lts.is-demo #panelCss,
.lts.is-demo #panelScss,
.lts.is-demo #panelTw {
animation: none !important;
}
.lts.is-demo #panelScss,
.lts.is-demo #panelTw {
opacity: 0;
}
}var root = document.getElementById('root');
var tablist = document.getElementById('tablist');
var indicator = document.getElementById('indicator');
var tabs = [document.getElementById('tabCss'), document.getElementById('tabScss'), document.getElementById('tabTw')];
var panels = [document.getElementById('panelCss'), document.getElementById('panelScss'), document.getElementById('panelTw')];
function moveIndicator(tab) {
var tabsRect = tablist.getBoundingClientRect();
var r = tab.getBoundingClientRect();
var base = tabs[0].getBoundingClientRect().width;
var left = r.left - tabsRect.left;
var scale = r.width / base;
indicator.style.transform = 'translateX(' + left + 'px) scaleX(' + scale + ')';
}
function select(i) {
tabs.forEach(function (t, idx) {
var on = idx === i;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.tabIndex = on ? 0 : -1;
panels[idx].hidden = !on;
});
moveIndicator(tabs[i]);
}
tabs.forEach(function (t, i) {
t.addEventListener('click', function () {
root.classList.remove('is-demo');
select(i);
});
});
tablist.addEventListener('keydown', function (e) {
var i = tabs.findIndex(function (t) { return t.getAttribute('aria-selected') === 'true'; });
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
root.classList.remove('is-demo');
var next = e.key === 'ArrowRight' ? (i + 1) % tabs.length : (i - 1 + tabs.length) % tabs.length;
select(next);
tabs[next].focus({ preventScroll: true });
e.preventDefault();
}
});import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./LanguageTabSwitch.module.scss";
export type LanguageTabSwitchProps = {
tablistLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T | null };
type KeyEvt = { key: string; preventDefault(): void };
const LANGS = ["css", "scss", "tailwind"] as const;
type Lang = (typeof LANGS)[number];
const LABELS: Record<Lang, string> = { css: "CSS", scss: "SCSS", tailwind: "Tailwind" };
const TAB_CLASS_KEY: Record<Lang, "tabCss" | "tabScss" | "tabTw"> = { css: "tabCss", scss: "tabScss", tailwind: "tabTw" };
function CssCode() {
return (
<code>
<span className={styles.k}>.btn</span> {"{"}
{"\n "}
<span className={styles.k}>display</span>: <span className={styles.s}>flex</span>;
{"\n "}
<span className={styles.k}>align-items</span>: <span className={styles.s}>center</span>;
{"\n "}
<span className={styles.k}>gap</span>: <span className={styles.n}>8px</span>;
{"\n "}
<span className={styles.k}>padding</span>: <span className={styles.n}>8px</span> <span className={styles.n}>16px</span>;
{"\n}"}
</code>
);
}
function ScssCode() {
return (
<code>
<span className={styles.k}>.btn</span> {"{"}
{"\n "}
<span className={styles.k}>display</span>: <span className={styles.s}>flex</span>;
{"\n "}
<span className={styles.k}>gap</span>: <span className={styles.n}>8px</span>;
{"\n "}
<span className={styles.k}>&:hover</span> {"{ "}
<span className={styles.k}>opacity</span>: <span className={styles.n}>.8</span>; {"}"}
{"\n}"}
</code>
);
}
function TailwindCode() {
return (
<code>
<span className={styles.c}>{'<button class="'}</span>
{"\n "}
<span className={styles.s}>flex items-center gap-2</span>
{"\n "}
<span className={styles.s}>px-4 py-2 rounded-xl</span>
{"\n"}
<span className={styles.c}>{'">'}</span>
{"\n Copy\n"}
<span className={styles.c}>{"</button>"}</span>
</code>
);
}
const CODES: Record<Lang, () => ReactNode> = { css: CssCode, scss: ScssCode, tailwind: TailwindCode };
export default function LanguageTabSwitch({
tablistLabel = "Select language",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: LanguageTabSwitchProps) {
const [live, setLive] = useState(demo);
const [active, setActive] = useState<Lang>("css");
const [ind, setInd] = useState<{ left: number; scale: number }>({ left: 0, scale: 1 });
const tablistRef = useRef<HTMLDivElement>(null) as Box<HTMLDivElement>;
const tabRefs = useRef<Record<Lang, HTMLButtonElement | null>>({ css: null, scss: null, tailwind: null }) as Box<
Record<Lang, HTMLButtonElement | null>
>;
function measure(lang: Lang) {
const list = tablistRef.current;
const map = tabRefs.current;
if (!list || !map) return;
const base = map.css;
const tab = map[lang];
if (!base || !tab) return;
const listRect = list.getBoundingClientRect();
const baseRect = base.getBoundingClientRect();
const tabRect = tab.getBoundingClientRect();
setInd({ left: tabRect.left - listRect.left, scale: tabRect.width / baseRect.width });
}
function select(lang: Lang) {
setLive(false);
setActive(lang);
measure(lang);
}
function onKeyDown(e: KeyEvt) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const i = LANGS.indexOf(active);
const next = e.key === "ArrowRight" ? (i + 1) % LANGS.length : (i - 1 + LANGS.length) % LANGS.length;
select(LANGS[next]);
const btn = tabRefs.current ? tabRefs.current[LANGS[next]] : null;
if (btn) btn.focus({ preventScroll: true });
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const ActiveCode = CODES[active];
function setTabRef(lang: Lang, el: HTMLButtonElement | null) {
const map = tabRefs.current;
if (map) map[lang] = el;
}
return (
<div className={[styles.lts, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div className={styles.pane}>
<div className={styles.paneHead}>
<div
className={styles.tabs}
role="tablist"
aria-label={tablistLabel}
ref={tablistRef}
onKeyDown={onKeyDown}
>
{LANGS.map((lang) => (
<button
key={lang}
className={[styles.tab, styles[TAB_CLASS_KEY[lang]]].join(" ")}
role="tab"
type="button"
aria-selected={live ? undefined : active === lang}
tabIndex={live ? -1 : active === lang ? 0 : -1}
ref={(el: HTMLButtonElement | null) => setTabRef(lang, el)}
onClick={() => select(lang)}
>
{LABELS[lang]}
</button>
))}
<span
className={styles.tabsIndicator}
aria-hidden="true"
style={live ? undefined : { transform: `translateX(${ind.left}px) scaleX(${ind.scale})` }}
/>
</div>
</div>
<div className={styles.paneBody}>
{live ? (
<>
<pre className={[styles.panel, styles.panelCss].join(" ")}>
<CssCode />
</pre>
<pre className={[styles.panel, styles.panelScss].join(" ")}>
<ScssCode />
</pre>
<pre className={[styles.panel, styles.panelTw].join(" ")}>
<TailwindCode />
</pre>
</>
) : (
<pre className={styles.panel}>
<ActiveCode />
</pre>
)}
</div>
</div>
</div>
);
}02 · 같은 예제를 언어별로 바꾸는 탭 — 측정 누적면적 2.667% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
탭 폭을 숫자로 미리 정해 두지 않고 매번 재기 때문에 라벨 글자 수가 달라져도 막대가 옆 탭을 침범하지 않습니다.
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 + ')';
}
03줄 번호와 짚어 주는 줄
어떤 움직임인가
줄 번호는 <span>에 타이핑된 글자가 아니라 counter-increment 로 세어 ::before 에 찍히므로, 코드를 전체 선택해 복사해도 번호는 따라오지 않습니다.

<main class="stage">
<div class="lnh is-demo" id="root">
<div class="pane">
<div class="pane__head">
<span class="pane__lang">CSS</span>
</div>
<div class="pane__body">
<pre id="codeText"><code class="lines"><span class="ln">.card {</span><span class="ln"> display: grid;</span><span class="ln is-called" id="calledLine" tabindex="0" aria-describedby="tip"> gap: 12px;</span><span class="ln"> padding: 16px;</span><span class="ln">}</span></code></pre>
</div>
</div>
<div class="tooltip" id="tip" role="tooltip">카드 사이 간격</div>
<span class="lnh__cursor" id="cursor" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-mint;
$syn-key: $stage-orange;
$syn-str: $stage-yellow;
$syn-num: $subject-sky;
$syn-dim: rgba($subject-cream, .72);
$syn-txt: $subject-cream;
@include stage($stage-orange);
.lnh {
position: relative;
box-sizing: border-box;
padding-bottom: 48px;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
color: $subject-cream;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
background: rgba(255, 247, 230, .06);
border-bottom: 1px solid rgba(255, 247, 230, .12);
}
.pane__lang {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: .02em;
color: $syn-dim;
text-transform: uppercase;
}
.pane__body {
position: relative;
box-sizing: border-box;
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.pane__body pre, .pane__body code { margin: 0; font: inherit; }
.lines { counter-reset: ln; display: block; }
.ln {
position: relative;
display: block;
box-sizing: border-box;
padding-left: 24px;
color: $syn-txt;
white-space: pre;
border-radius: $r-xs;
}
.ln::before {
counter-increment: ln;
content: counter(ln);
position: absolute;
left: 0;
top: 0;
width: 16px;
text-align: right;
color: $syn-dim;
user-select: none;
}
.ln.is-called {
cursor: pointer;
background: rgba($color, .10);
}
.ln.is-called:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
.ln.is-called::after {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
border-radius: $r-xs;
background: $color;
transform-origin: top center; // 규칙8: 하이라이트 막대 위에서
transform: scaleY(0);
}
.lnh.is-open .ln.is-called {
background: rgba($color, .22);
transition: background $duration $easing;
}
.lnh.is-open .ln.is-called::after {
transform: scaleY(1);
transition: transform $duration $easing;
}
.tooltip {
// .pane 은 헤더 28px + 본문(패딩 $sp-3×2 + 5줄×18px) = 142px 로 고정 계산 가능 —
// top:100% 는 .lnh 의 padding-bottom(예약분) 까지 포함해 계산되어 버리므로(Trap #7),
// 반드시 .pane 실측 높이를 px 로 고정해 그 아래에 배치한다
position: absolute;
left: $sp-3;
right: $sp-3;
top: 142px;
margin-top: $sp-2;
box-sizing: border-box;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba(255, 247, 230, .96);
color: $stage-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
box-shadow: $shadow-raised;
opacity: 0;
transform-origin: top center; // 규칙8: 패널 아래변에서 펼침
transform: translateY(6px) scale(.96);
pointer-events: none;
}
.lnh.is-open .tooltip {
opacity: 1;
transform: translateY(0) scale(1);
transition: opacity $duration $easing, transform $duration $easing;
}
.lnh__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid $subject-cream;
border-radius: 50%;
background: $stage-ink;
box-shadow: $shadow-press;
pointer-events: none;
opacity: 0;
transform: translate(16px, 20px);
}
.lnh.is-demo .lnh__cursor { opacity: 1; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 등장(≈9~19%, 0.2s)은 짧은 구간에 몰아 급격하게, 퇴장(≈78~94%)은 넓은 구간에 걸쳐 서서히
// — poster 가 「닫히는 순간」이 아니라 「짚어서 뜨는 순간」이 되도록 한다
.lnh.is-demo .tooltip {
animation-name: lnh-tooltip;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lnh.is-demo .ln.is-called {
animation-name: lnh-bg;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lnh.is-demo .ln.is-called::after {
animation-name: lnh-bar;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lnh.is-demo .lnh__cursor {
animation-name: lnh-cursor;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes lnh-tooltip {
0%, 9% { opacity: 0; transform: translateY(6px) scale(.96); }
19% { opacity: 1; transform: translateY(0) scale(1); }
78% { opacity: 1; transform: translateY(0) scale(1); }
94% { opacity: 0; transform: translateY(6px) scale(.96); }
100% { opacity: 0; transform: translateY(6px) scale(.96); }
}
@keyframes lnh-bg {
0%, 18% { background: rgba($color, .10); }
20% { background: rgba($color, .24); }
74% { background: rgba($color, .24); }
92% { background: rgba($color, .10); }
100% { background: rgba($color, .10); }
}
@keyframes lnh-bar {
0%, 20% { transform: scaleY(0); }
34% { transform: scaleY(1); }
78% { transform: scaleY(1); }
94% { transform: scaleY(0); }
100% { transform: scaleY(0); }
}
@keyframes lnh-cursor {
// 3번 줄 글자(" gap: 12px;")는 절대좌표 x 36~115 에 찍힌다 — 커서는 그 오른쪽 빈칸(x≥130)에서만 훑는다
0%, 2% { transform: translate(16px, 20px); }
18% { transform: translate(150px, 84px); }
34% { transform: translate(180px, 84px); }
52% { transform: translate(140px, 84px); }
72% { transform: translate(170px, 84px); }
86%, 100% { transform: translate(16px, 20px); }
}
@media (max-width: 340px) {
// 좁은 폭에서 .pane 높이 = 헤더 28px + 본문(패딩 $sp-2×2 + 5줄×16px) = 124px
.lnh { padding-bottom: 32px; }
.pane { width: 100%; }
.pane__body { font-size: 10px; line-height: 16px; padding: $sp-2; }
.ln { padding-left: 20px; }
.tooltip {
top: 124px;
margin-top: $sp-1;
left: $sp-2;
right: $sp-2;
padding: $sp-1 $sp-2;
}
}
@media (prefers-reduced-motion: reduce) {
.lnh.is-demo .tooltip,
.lnh.is-demo .ln.is-called,
.lnh.is-demo .ln.is-called::after,
.lnh.is-demo .lnh__cursor { animation: none !important; }
.lnh.is-demo .lnh__cursor { opacity: 0; }
.lnh.is-demo .tooltip { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lnh {
position: relative;
box-sizing: border-box;
padding-bottom: 48px;
}
.pane {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
color: #fff7e6;
}
.pane__head {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 12px;
background: rgba(255, 247, 230, 0.06);
border-bottom: 1px solid rgba(255, 247, 230, 0.12);
}
.pane__lang {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(255, 247, 230, 0.72);
text-transform: uppercase;
}
.pane__body {
position: relative;
box-sizing: border-box;
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.pane__body pre, .pane__body code {
margin: 0;
font: inherit;
}
.lines {
counter-reset: ln;
display: block;
}
.ln {
position: relative;
display: block;
box-sizing: border-box;
padding-left: 24px;
color: #fff7e6;
white-space: pre;
border-radius: 4px;
}
.ln::before {
counter-increment: ln;
content: counter(ln);
position: absolute;
left: 0;
top: 0;
width: 16px;
text-align: right;
color: rgba(255, 247, 230, 0.72);
user-select: none;
}
.ln.is-called {
cursor: pointer;
background: rgba(76, 212, 166, 0.1);
}
.ln.is-called:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 1px;
}
.ln.is-called::after {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
border-radius: 4px;
background: #4cd4a6;
transform-origin: top center;
transform: scaleY(0);
}
.lnh.is-open .ln.is-called {
background: rgba(76, 212, 166, 0.22);
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lnh.is-open .ln.is-called::after {
transform: scaleY(1);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tooltip {
position: absolute;
left: 12px;
right: 12px;
top: 142px;
margin-top: 8px;
box-sizing: border-box;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.96);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 11px;
font-weight: 700;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform-origin: top center;
transform: translateY(6px) scale(0.96);
pointer-events: none;
}
.lnh.is-open .tooltip {
opacity: 1;
transform: translateY(0) scale(1);
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lnh__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 12px;
height: 12px;
border: 2px solid #fff7e6;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
pointer-events: none;
opacity: 0;
transform: translate(16px, 20px);
}
.lnh.is-demo .lnh__cursor {
opacity: 1;
}
.lnh.is-demo .tooltip {
animation-name: lnh-tooltip;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lnh.is-demo .ln.is-called {
animation-name: lnh-bg;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lnh.is-demo .ln.is-called::after {
animation-name: lnh-bar;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lnh.is-demo .lnh__cursor {
animation-name: lnh-cursor;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes lnh-tooltip {
0%, 9% {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
19% {
opacity: 1;
transform: translateY(0) scale(1);
}
78% {
opacity: 1;
transform: translateY(0) scale(1);
}
94% {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
100% {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
}
@keyframes lnh-bg {
0%, 18% {
background: rgba(76, 212, 166, 0.1);
}
20% {
background: rgba(76, 212, 166, 0.24);
}
74% {
background: rgba(76, 212, 166, 0.24);
}
92% {
background: rgba(76, 212, 166, 0.1);
}
100% {
background: rgba(76, 212, 166, 0.1);
}
}
@keyframes lnh-bar {
0%, 20% {
transform: scaleY(0);
}
34% {
transform: scaleY(1);
}
78% {
transform: scaleY(1);
}
94% {
transform: scaleY(0);
}
100% {
transform: scaleY(0);
}
}
@keyframes lnh-cursor {
0%, 2% {
transform: translate(16px, 20px);
}
18% {
transform: translate(150px, 84px);
}
34% {
transform: translate(180px, 84px);
}
52% {
transform: translate(140px, 84px);
}
72% {
transform: translate(170px, 84px);
}
86%, 100% {
transform: translate(16px, 20px);
}
}
@media (max-width: 340px) {
.lnh {
padding-bottom: 32px;
}
.pane {
width: 100%;
}
.pane__body {
font-size: 10px;
line-height: 16px;
padding: 8px;
}
.ln {
padding-left: 20px;
}
.tooltip {
top: 124px;
margin-top: 4px;
left: 8px;
right: 8px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.lnh.is-demo .tooltip,
.lnh.is-demo .ln.is-called,
.lnh.is-demo .ln.is-called::after,
.lnh.is-demo .lnh__cursor {
animation: none !important;
}
.lnh.is-demo .lnh__cursor {
opacity: 0;
}
.lnh.is-demo .tooltip {
opacity: 0;
}
}var root = document.getElementById('root');
var line = document.getElementById('calledLine');
function open() {
root.classList.remove('is-demo');
root.classList.add('is-open');
}
function close() {
root.classList.remove('is-open');
}
line.addEventListener('mouseenter', open);
line.addEventListener('mouseleave', close);
line.addEventListener('focus', open);
line.addEventListener('blur', close);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LineNumberHighlight.module.scss";
export type LineNumberHighlightProps = {
langLabel?: string;
tooltipText?: string;
/** Auto-play loop (for gallery). Turns off on real interaction */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const LINES = [".card {", " display: grid;", " gap: 12px;", " padding: 16px;", "}"];
const CALLED_INDEX = 2;
export default function LineNumberHighlight({
langLabel = "CSS",
tooltipText = "Gap between cards",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: LineNumberHighlightProps) {
const [live, setLive] = useState(demo);
const [open, setOpen] = useState(false);
const [tipId] = useState(() => "lnh-tip-" + Math.random().toString(36).slice(2));
function show() {
setLive(false);
setOpen(true);
}
function hide() {
setOpen(false);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={[styles.lnh, live ? styles.isDemo : "", open ? styles.isOpen : ""].join(" ")} style={vars}>
<div className={styles.pane}>
<div className={styles.paneHead}>
<span className={styles.paneLang}>{langLabel}</span>
</div>
<div className={styles.paneBody}>
<pre>
<code className={styles.lines}>
{LINES.map((text, i) =>
i === CALLED_INDEX ? (
<span
key={i}
className={[styles.ln, styles.isCalled].join(" ")}
tabIndex={0}
aria-describedby={tipId}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
>
{text}
</span>
) : (
<span key={i} className={styles.ln}>
{text}
</span>
)
)}
</code>
</pre>
</div>
</div>
<div className={styles.tooltip} id={tipId} role="tooltip">
{tooltipText}
</div>
<span className={styles.lnhCursor} aria-hidden="true" />
</div>
);
}03 · 줄 번호와 짚어 주는 줄 — 측정 누적면적 14.751% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
짚어 둔 줄에 포인터를 올리거나 탭으로 초점을 주면 aria-describedby 로 연결된 쪽지가 뜹니다.
.lines { counter-reset: ln; display: block; }
.ln {
position: relative;
padding-left: 24px;
white-space: pre;
}
.ln::before {
counter-increment: ln;
content: counter(ln);
position: absolute;
left: 0;
width: 16px;
text-align: right;
user-select: none;
}
04더한 줄과 뺀 줄을 나눠 보는 diff
무엇이 보이나
더한 줄은 왼쪽에 초록 띠, 뺀 줄은 붉은 띠를 두르고 그 색은 +·- 기호 칸에도 같이 입혀 색과 기호가 같은 정보를 두 번 말합니다. 기호는 코드 본문과 다른 <span> 안에 있어 실제로 선택해 복사하면 부호 없이 코드만 붙습니다.

<main class="stage">
<div class="cd is-demo" id="cd">
<div class="cd__pane">
<div class="cd__head">
<span class="cd__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M80 64a24 24 0 1 1-24-24a24 24 0 0 1 24 24m120 104a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M112 152a8 8 0 0 0-8 8v28.69L66.34 151a8 8 0 0 1-2.34-5.63V95a32 32 0 1 0-16 0v50.38a23.85 23.85 0 0 0 7 17L92.69 200H64a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8M40 64a16 16 0 1 1 16 16a16 16 0 0 1-16-16m168 97v-50.37a23.85 23.85 0 0 0-7-17L163.31 56H192a8 8 0 0 0 0-16h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0V67.31L189.66 105a8 8 0 0 1 2.34 5.66V161a32 32 0 1 0 16 0m-8 47a16 16 0 1 1 16-16a16 16 0 0 1-16 16"/></g></svg></span>
<span class="cd__file">config.css</span>
</div>
<span class="cd__cursor" aria-hidden="true"></span>
<div class="cd__body">
<div class="cd__line cd__line--top">
<span class="cd__sign" aria-hidden="true"> </span><span class="cd__src"><span class="k">.card</span> <span class="p">{</span></span>
</div>
<button class="cd__fold" id="cd-fold" type="button" aria-expanded="false" aria-controls="cd-hidden" aria-label="가려진 줄 펼치기">
<span class="cd__sign" aria-hidden="true"> </span><span class="cd__src cd__dots">···</span>
</button>
<div class="cd__fold-body" id="cd-hidden">
<div class="cd__fold-in">
<div class="cd__line">
<span class="cd__sign" aria-hidden="true"> </span><span class="cd__src"> <span class="k">display</span><span class="p">:</span> <span class="s">flex</span><span class="p">;</span></span>
</div>
<div class="cd__line">
<span class="cd__sign" aria-hidden="true"> </span><span class="cd__src"> <span class="k">align-items</span><span class="p">:</span> <span class="s">center</span><span class="p">;</span></span>
</div>
</div>
</div>
<div class="cd__line cd__line--del cd__line--del1">
<span class="cd__sign">-</span><span class="cd__src"> <span class="k">padding</span><span class="p">:</span> <span class="n">12px 16px</span><span class="p">;</span></span>
</div>
<div class="cd__line cd__line--del cd__line--del2">
<span class="cd__sign">-</span><span class="cd__src"> <span class="k">border-radius</span><span class="p">:</span> <span class="n">4px</span><span class="p">;</span></span>
</div>
<div class="cd__line cd__line--add cd__line--add1">
<span class="cd__sign">+</span><span class="cd__src"> <span class="k">padding</span><span class="p">:</span> <span class="n">16px 20px</span><span class="p">;</span></span>
</div>
<div class="cd__line cd__line--add cd__line--add2">
<span class="cd__sign">+</span><span class="cd__src"> <span class="k">border-radius</span><span class="p">:</span> <span class="n">12px</span><span class="p">;</span></span>
</div>
<div class="cd__line cd__line--top">
<span class="cd__sign" aria-hidden="true"> </span><span class="cd__src"><span class="p">}</span></span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-paper);
// 구문 색 — 어두운 패널
$syn-key: $stage-orange;
$syn-str: $stage-yellow;
$syn-num: $subject-sky;
$syn-dim: rgba($subject-cream, .72);
$syn-txt: $subject-cream;
.cd {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cd__pane {
position: relative;
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
color: $syn-txt;
}
// 포인터가 접힘 줄 쪽으로 다가갔다가 돌아오는 작은 신호 — 실제 커서를 그리지 않고
// 옅은 점 하나로 시선의 이동만 암시한다 (면적은 작아 대표 프레임 선정에 영향 없음)
.cd__cursor {
position: absolute;
inset-inline-end: $sp-2;
top: 34px;
width: 6px;
height: 6px;
border-radius: 50%;
background: $subject-sky;
box-shadow: 0 0 0 4px rgba($subject-sky, .18);
opacity: 0;
pointer-events: none;
}
.cd__head {
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
background: rgba(255, 247, 230, .08);
}
.cd__ic { display: inline-flex; flex: 0 0 auto; color: $subject-sky; }
.cd__ic svg { display: block; }
.cd__file {
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 16px;
font-weight: 700;
color: $syn-txt;
}
.cd__body {
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.cd__line {
display: flex;
align-items: flex-start;
}
.cd__sign {
flex: 0 0 auto;
width: 12px;
color: $syn-dim;
user-select: none;
}
.cd__src { flex: 1 1 auto; min-width: 0; white-space: pre; }
.cd__src .k { color: $syn-key; }
.cd__src .s { color: $syn-str; }
.cd__src .n { color: $syn-num; }
.cd__src .p { color: $syn-dim; }
// 더한 줄 · 뺀 줄 — 왼쪽 띠 + 그 색의 옅은 배경
.cd__line--add {
border-inline-start: 2px solid $color;
background: rgba($color, .16);
}
.cd__line--add .cd__sign { color: $color; font-weight: 700; }
.cd__line--del {
border-inline-start: 2px solid $error;
background: rgba($error, .18);
}
.cd__line--del .cd__sign { color: $error-soft; font-weight: 700; }
// 옅은 민트·붉은 배경이 깔린 줄은 무대(먹)보다 밝다 — 그 위의 주황 키워드는 4.02:1 로 모자란다.
// ⑤(어두운 면 위 파란 글자 → $subject-blue-light)와 같은 방식으로, 토큰을 섞어 한 단계 밝힌 주황을 쓴다.
.cd__line--add .cd__src .k,
.cd__line--del .cd__src .k { color: mix($stage-orange, $subject-cream, 70%); }
.cd__line--top .cd__src { color: $syn-dim; }
// 왼쪽 띠 자체가 자라는 연출용 레이어 — 실제 테두리 위에 덧대는 게 아니라
// 테두리는 늘 실재(정적 상태에서도 보인다)하고, 이 안쪽 살짝 밝은 켜짐만 스케일로 차오른다
.cd__line--add,
.cd__line--del { position: relative; }
.cd__line--add::before,
.cd__line--del::before {
content: "";
position: absolute;
inset-inline-start: -2px;
inset-block: 0;
width: 2px;
transform: scaleY(1);
transform-origin: top center; // 규칙8: diff 띠 위에서 차오름
}
.cd__line--add::before { background: $color; }
.cd__line--del::before { background: $error; }
// ── 접힌 중간 줄 ───────────────────────────────────────
.cd__fold {
display: flex;
align-items: flex-start;
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
cursor: pointer;
}
.cd__fold:focus-visible { outline: 2px solid $subject-sky; outline-offset: 1px; }
.cd__dots { color: $syn-dim; letter-spacing: 2px; }
.cd__fold-body {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows $duration $easing;
}
.cd.is-open .cd__fold-body { grid-template-rows: 1fr; }
.cd__fold-in { min-height: 0; }
// ── 자동 루프 (2s) ─────────────────────────────────────
// 0~14% 정지 · 16%(≈14~30%) 접힘 줄이 부드럽게 펴짐(0fr→1fr) · 20~40% 추가/삭제 띠가 시차를 두고
// 차오름 · 44~72% 정지 · 82~86% 다시 접힘(빠른 컷) · 84~100% 정지. 0%=100% 상태가 같아 이음매가 없다
.cd.is-demo .cd__fold-body {
animation-name: cd-fold;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cd-fold {
0% { grid-template-rows: 0fr; }
14% { grid-template-rows: 0fr; animation-timing-function: $easing; }
30% { grid-template-rows: 1fr; }
82% { grid-template-rows: 1fr; animation-timing-function: steps(1, end); }
86% { grid-template-rows: 0fr; }
100% { grid-template-rows: 0fr; }
}
.cd.is-demo .cd__dots {
animation-name: cd-dots;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cd-dots {
0% { opacity: 1; }
30% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
// 추가·삭제 줄의 띠 — 지연을 다르게 줘 차례로 차오르게 한다. 지연 동안은 backwards 로 0 을 유지해
// 0프레임에 완성 상태가 번쩍이지 않게 한다 (함정 6)
.cd.is-demo .cd__line--del1::before,
.cd.is-demo .cd__line--del2::before,
.cd.is-demo .cd__line--add1::before,
.cd.is-demo .cd__line--add2::before {
animation-name: cd-bar;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cd.is-demo .cd__line--del1::before { animation-delay: 0ms; }
.cd.is-demo .cd__line--del2::before { animation-delay: 70ms; }
.cd.is-demo .cd__line--add1::before { animation-delay: 140ms; }
.cd.is-demo .cd__line--add2::before { animation-delay: 210ms; }
@keyframes cd-bar {
0% { transform: scaleY(0); }
16% { transform: scaleY(0); }
30% { transform: scaleY(1); }
100% { transform: scaleY(1); }
}
.cd.is-demo .cd__cursor {
animation-name: cd-cursor;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cd-cursor {
0% { opacity: 0; transform: translateY(0); }
6% { opacity: .9; transform: translateY(0); }
14% { opacity: .9; transform: translateY(54px); animation-timing-function: steps(1, end); }
18% { opacity: 0; transform: translateY(54px); }
44% { opacity: 0; transform: translateY(54px); }
50% { opacity: .9; transform: translateY(54px); }
72% { opacity: .9; transform: translateY(0); }
80% { opacity: 0; transform: translateY(0); }
100% { opacity: 0; transform: translateY(0); }
}
@media (max-width: 340px) {
.cd__pane { width: 100%; }
.cd__body,
.cd__fold { font-size: 10px; line-height: 16px; padding: $sp-2; }
.cd__body { padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-demo .cd__fold-body,
.cd.is-demo .cd__dots,
.cd.is-demo .cd__cursor,
.cd.is-demo .cd__line--del1::before,
.cd.is-demo .cd__line--del2::before,
.cd.is-demo .cd__line--add1::before,
.cd.is-demo .cd__line--add2::before { animation: none !important; }
.cd.is-demo .cd__cursor { opacity: 0; }
.cd.is-demo .cd__line--del1::before,
.cd.is-demo .cd__line--del2::before,
.cd.is-demo .cd__line--add1::before,
.cd.is-demo .cd__line--add2::before { transform: scaleY(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cd__pane {
position: relative;
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
color: #fff7e6;
}
.cd__cursor {
position: absolute;
inset-inline-end: 8px;
top: 34px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #6ec8ff;
box-shadow: 0 0 0 4px rgba(110, 200, 255, 0.18);
opacity: 0;
pointer-events: none;
}
.cd__head {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 12px;
background: rgba(255, 247, 230, 0.08);
}
.cd__ic {
display: inline-flex;
flex: 0 0 auto;
color: #6ec8ff;
}
.cd__ic svg {
display: block;
}
.cd__file {
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 16px;
font-weight: 700;
color: #fff7e6;
}
.cd__body {
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.cd__line {
display: flex;
align-items: flex-start;
}
.cd__sign {
flex: 0 0 auto;
width: 12px;
color: rgba(255, 247, 230, 0.72);
user-select: none;
}
.cd__src {
flex: 1 1 auto;
min-width: 0;
white-space: pre;
}
.cd__src .k {
color: #ff4d1f;
}
.cd__src .s {
color: #ffd23f;
}
.cd__src .n {
color: #6ec8ff;
}
.cd__src .p {
color: rgba(255, 247, 230, 0.72);
}
.cd__line--add {
border-inline-start: 2px solid #4cd4a6;
background: rgba(76, 212, 166, 0.16);
}
.cd__line--add .cd__sign {
color: #4cd4a6;
font-weight: 700;
}
.cd__line--del {
border-inline-start: 2px solid #c2361a;
background: rgba(194, 54, 26, 0.18);
}
.cd__line--del .cd__sign {
color: #ffe0d8;
font-weight: 700;
}
.cd__line--add .cd__src .k,
.cd__line--del .cd__src .k {
color: rgb(100%, 50.1960784314%, 35.568627451%);
}
.cd__line--top .cd__src {
color: rgba(255, 247, 230, 0.72);
}
.cd__line--add,
.cd__line--del {
position: relative;
}
.cd__line--add::before,
.cd__line--del::before {
content: "";
position: absolute;
inset-inline-start: -2px;
inset-block: 0;
width: 2px;
transform: scaleY(1);
transform-origin: top center;
}
.cd__line--add::before {
background: #4cd4a6;
}
.cd__line--del::before {
background: #c2361a;
}
.cd__fold {
display: flex;
align-items: flex-start;
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
cursor: pointer;
}
.cd__fold:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 1px;
}
.cd__dots {
color: rgba(255, 247, 230, 0.72);
letter-spacing: 2px;
}
.cd__fold-body {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cd.is-open .cd__fold-body {
grid-template-rows: 1fr;
}
.cd__fold-in {
min-height: 0;
}
.cd.is-demo .cd__fold-body {
animation-name: cd-fold;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cd-fold {
0% {
grid-template-rows: 0fr;
}
14% {
grid-template-rows: 0fr;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
30% {
grid-template-rows: 1fr;
}
82% {
grid-template-rows: 1fr;
animation-timing-function: steps(1, end);
}
86% {
grid-template-rows: 0fr;
}
100% {
grid-template-rows: 0fr;
}
}
.cd.is-demo .cd__dots {
animation-name: cd-dots;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cd-dots {
0% {
opacity: 1;
}
30% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.cd.is-demo .cd__line--del1::before,
.cd.is-demo .cd__line--del2::before,
.cd.is-demo .cd__line--add1::before,
.cd.is-demo .cd__line--add2::before {
animation-name: cd-bar;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cd.is-demo .cd__line--del1::before {
animation-delay: 0ms;
}
.cd.is-demo .cd__line--del2::before {
animation-delay: 70ms;
}
.cd.is-demo .cd__line--add1::before {
animation-delay: 140ms;
}
.cd.is-demo .cd__line--add2::before {
animation-delay: 210ms;
}
@keyframes cd-bar {
0% {
transform: scaleY(0);
}
16% {
transform: scaleY(0);
}
30% {
transform: scaleY(1);
}
100% {
transform: scaleY(1);
}
}
.cd.is-demo .cd__cursor {
animation-name: cd-cursor;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cd-cursor {
0% {
opacity: 0;
transform: translateY(0);
}
6% {
opacity: 0.9;
transform: translateY(0);
}
14% {
opacity: 0.9;
transform: translateY(54px);
animation-timing-function: steps(1, end);
}
18% {
opacity: 0;
transform: translateY(54px);
}
44% {
opacity: 0;
transform: translateY(54px);
}
50% {
opacity: 0.9;
transform: translateY(54px);
}
72% {
opacity: 0.9;
transform: translateY(0);
}
80% {
opacity: 0;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(0);
}
}
@media (max-width: 340px) {
.cd__pane {
width: 100%;
}
.cd__body,
.cd__fold {
font-size: 10px;
line-height: 16px;
padding: 8px;
}
.cd__body {
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-demo .cd__fold-body,
.cd.is-demo .cd__dots,
.cd.is-demo .cd__cursor,
.cd.is-demo .cd__line--del1::before,
.cd.is-demo .cd__line--del2::before,
.cd.is-demo .cd__line--add1::before,
.cd.is-demo .cd__line--add2::before {
animation: none !important;
}
.cd.is-demo .cd__cursor {
opacity: 0;
}
.cd.is-demo .cd__line--del1::before,
.cd.is-demo .cd__line--del2::before,
.cd.is-demo .cd__line--add1::before,
.cd.is-demo .cd__line--add2::before {
transform: scaleY(1);
}
}// 실제 동작: 접힌 중간 줄을 펼치고 접는다. 사람이 누르면 자동 루프를 멈춘다
var cd = document.getElementById('cd');
var fold = document.getElementById('cd-fold');
fold.addEventListener('click', function () {
cd.classList.remove('is-demo');
var open = fold.getAttribute('aria-expanded') === 'true';
fold.setAttribute('aria-expanded', String(!open));
fold.setAttribute('aria-label', open ? '가려진 줄 펼치기' : '가려진 줄 접기');
cd.classList.toggle('is-open', !open);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DiffAddedRemoved.module.scss";
export type DiffAddedRemovedProps = {
fileName?: string;
openLabel?: string;
closeLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function DiffIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path
d="M80 64a24 24 0 1 1-24-24a24 24 0 0 1 24 24m120 104a24 24 0 1 0 24 24a24 24 0 0 0-24-24"
opacity=".2"
/>
<path d="M112 152a8 8 0 0 0-8 8v28.69L66.34 151a8 8 0 0 1-2.34-5.63V95a32 32 0 1 0-16 0v50.38a23.85 23.85 0 0 0 7 17L92.69 200H64a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8M40 64a16 16 0 1 1 16 16a16 16 0 0 1-16-16m168 97v-50.37a23.85 23.85 0 0 0-7-17L163.31 56H192a8 8 0 0 0 0-16h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0V67.31L189.66 105a8 8 0 0 1 2.34 5.66V161a32 32 0 1 0 16 0m-8 47a16 16 0 1 1 16-16a16 16 0 0 1-16 16" />
</g>
</svg>
);
}
export default function DiffAddedRemoved({
fileName = "config.css",
openLabel = "Show hidden lines",
closeLabel = "Hide expanded lines",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: DiffAddedRemovedProps) {
const [open, setOpen] = useState(false);
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.root, live ? styles.isDemo : "", open ? styles.isOpen : ""].join(" ");
function toggle() {
setLive(false);
setOpen(!open);
}
return (
<div className={cls} style={vars}>
<div className={styles.pane}>
<div className={styles.head}>
<span className={styles.ic} aria-hidden="true">
<DiffIcon />
</span>
<span className={styles.file}>{fileName}</span>
</div>
<span className={styles.cursor} aria-hidden="true" />
<div className={styles.body}>
<div className={[styles.line, styles.lineTop].join(" ")}>
<span className={styles.sign} aria-hidden="true">
{" "}
</span>
<span className={styles.src}>
<span className={styles.k}>.card</span> <span className={styles.p}>{"{"}</span>
</span>
</div>
<button
className={styles.fold}
type="button"
aria-expanded={open}
aria-controls="cd-hidden-lines"
aria-label={open ? closeLabel : openLabel}
onClick={toggle}
>
<span className={styles.sign} aria-hidden="true">
{" "}
</span>
<span className={[styles.src, styles.dots].join(" ")}>···</span>
</button>
<div className={styles.foldBody} id="cd-hidden-lines">
<div className={styles.foldIn}>
<div className={styles.line}>
<span className={styles.sign} aria-hidden="true">
{" "}
</span>
<span className={styles.src}>
<span className={styles.k}>display</span>
<span className={styles.p}>:</span> <span className={styles.s}>flex</span>
<span className={styles.p}>;</span>
</span>
</div>
<div className={styles.line}>
<span className={styles.sign} aria-hidden="true">
{" "}
</span>
<span className={styles.src}>
<span className={styles.k}>align-items</span>
<span className={styles.p}>:</span> <span className={styles.s}>center</span>
<span className={styles.p}>;</span>
</span>
</div>
</div>
</div>
<div className={[styles.line, styles.lineDel, styles.lineDel1].join(" ")}>
<span className={styles.sign}>-</span>
<span className={styles.src}>
<span className={styles.k}>padding</span>
<span className={styles.p}>:</span> <span className={styles.n}>12px 16px</span>
<span className={styles.p}>;</span>
</span>
</div>
<div className={[styles.line, styles.lineDel, styles.lineDel2].join(" ")}>
<span className={styles.sign}>-</span>
<span className={styles.src}>
<span className={styles.k}>border-radius</span>
<span className={styles.p}>:</span> <span className={styles.n}>4px</span>
<span className={styles.p}>;</span>
</span>
</div>
<div className={[styles.line, styles.lineAdd, styles.lineAdd1].join(" ")}>
<span className={styles.sign}>+</span>
<span className={styles.src}>
<span className={styles.k}>padding</span>
<span className={styles.p}>:</span> <span className={styles.n}>16px 20px</span>
<span className={styles.p}>;</span>
</span>
</div>
<div className={[styles.line, styles.lineAdd, styles.lineAdd2].join(" ")}>
<span className={styles.sign}>+</span>
<span className={styles.src}>
<span className={styles.k}>border-radius</span>
<span className={styles.p}>:</span> <span className={styles.n}>12px</span>
<span className={styles.p}>;</span>
</span>
</div>
<div className={[styles.line, styles.lineTop].join(" ")}>
<span className={styles.sign} aria-hidden="true">
{" "}
</span>
<span className={styles.src}>
<span className={styles.p}>{"}"}</span>
</span>
</div>
</div>
</div>
</div>
);
}04 · 더한 줄과 뺀 줄을 나눠 보는 diff — 측정 누적면적 28.963% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
가운데 안 바뀐 두 줄은 grid-template-rows 로 접혀 있다가 버튼을 눌러야 펴집니다.
.cd__line--add {
border-inline-start: 2px solid $color;
background: rgba($color, .16);
}
.cd__line--del {
border-inline-start: 2px solid $error;
background: rgba($error, .18);
}
.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; }
05길면 접었다가 펴는 코드 창
화면에서 일어나는 일
긴 파일은 grid-template-rows 를 0fr에서 1fr로 옮겨 여닫으므로, 자바스크립트로 콘텐츠 높이를 재서 px 로 박아 둘 필요가 없습니다.

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

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

<main class="stage">
<div class="term is-demo" id="term" role="button" tabindex="0" aria-label="다시 재생">
<div class="term__head">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z"/></g></svg>
<span class="term__name">bash</span>
</div>
<div class="term__body">
<div class="term__line">
<span class="term__prompt" aria-hidden="true">$</span>
<span class="term__cmd" id="term-cmd">npm i -D eslint prettier</span><span class="term__cursor" aria-hidden="true"></span>
</div>
<div class="term__out" id="term-out1"><span class="c">added </span><span class="n">2 packages</span><span class="c"> in 1.4s</span></div>
<div class="term__out" id="term-out2"><span class="c">audited </span><span class="n">128 packages</span><span class="c"> in 2.1s</span></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-orange);
// 구문 색 — 어두운 패널
$syn-key: $stage-orange;
$syn-str: $stage-yellow;
$syn-num: $subject-sky;
$syn-dim: rgba($subject-cream, .72);
$syn-txt: $subject-cream;
.term {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
cursor: pointer;
}
.term:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.term__head {
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
color: $syn-dim;
background: rgba(255, 247, 230, .08);
}
.term__name {
font-size: 11px;
font-weight: 700;
color: $syn-txt;
}
.term__body {
box-sizing: border-box;
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
color: $syn-txt;
}
.term__line { white-space: nowrap; }
.term__prompt { color: $syn-key; font-weight: 700; }
.term__cmd {
display: inline-block;
overflow: hidden;
white-space: nowrap;
vertical-align: bottom;
width: 24ch; // 정적 상태 — 명령이 이미 다 쳐진 채로 보인다
}
.term__cursor {
display: inline-block;
width: 7px;
height: 13px;
margin-left: 0;
vertical-align: -2px;
background: $subject-cream;
animation-name: term-blink;
animation-duration: 1s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.term__out {
margin-top: $sp-1;
opacity: 1; // 정적 상태 — 출력이 이미 보인다
transform: translateY(0);
}
.term__out .n { color: $syn-num; }
.term__out .c { color: $syn-dim; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~44% 타이핑(steps 24) · 46% 커서는 별도 요소라 폭이 늘면 저절로 줄 끝에 선다 ·
// 50~64% 출력1 등장 · 66~80% 출력2 등장 · 86% 컷으로 리셋(다음 루프 준비) · 86~100% 정지
.term.is-demo .term__cmd {
animation-name: term-type;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards; // 0% 프레임(폭 0)을 시작 전에도 고정 — 지연이 생겨도 깜빡임 없음
}
.term.is-demo #term-out1 {
animation-name: term-out1;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.term.is-demo #term-out2 {
animation-name: term-out2;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes term-type {
0% { width: 0ch; animation-timing-function: steps(24, end); }
44% { width: 24ch; animation-timing-function: steps(1, end); }
86% { width: 24ch; animation-timing-function: steps(1, end); }
100% { width: 0ch; }
}
@keyframes term-out1 {
0% { opacity: 0; transform: translateY(8px); }
50% { opacity: 0; transform: translateY(8px); animation-timing-function: $easing; }
64% { opacity: 1; transform: translateY(0); animation-timing-function: steps(1, end); }
86% { opacity: 1; transform: translateY(0); animation-timing-function: steps(1, end); }
100% { opacity: 0; transform: translateY(8px); }
}
@keyframes term-out2 {
0% { opacity: 0; transform: translateY(8px); }
66% { opacity: 0; transform: translateY(8px); animation-timing-function: $easing; }
80% { opacity: 1; transform: translateY(0); animation-timing-function: steps(1, end); }
86% { opacity: 1; transform: translateY(0); animation-timing-function: steps(1, end); }
100% { opacity: 0; transform: translateY(8px); }
}
@media (max-width: 340px) {
.term { width: 100%; }
.term__body { font-size: 10px; line-height: 16px; padding: $sp-2; }
.term__cursor { height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.term.is-demo .term__cmd,
.term.is-demo #term-out1,
.term.is-demo #term-out2,
.term__cursor { animation: none !important; }
.term.is-demo .term__cmd { width: 24ch; }
.term.is-demo #term-out1,
.term.is-demo #term-out2 { opacity: 1; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.term {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
cursor: pointer;
}
.term:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.term__head {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 12px;
color: rgba(255, 247, 230, 0.72);
background: rgba(255, 247, 230, 0.08);
}
.term__name {
font-size: 11px;
font-weight: 700;
color: #fff7e6;
}
.term__body {
box-sizing: border-box;
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
color: #fff7e6;
}
.term__line {
white-space: nowrap;
}
.term__prompt {
color: #ff4d1f;
font-weight: 700;
}
.term__cmd {
display: inline-block;
overflow: hidden;
white-space: nowrap;
vertical-align: bottom;
width: 24ch;
}
.term__cursor {
display: inline-block;
width: 7px;
height: 13px;
margin-left: 0;
vertical-align: -2px;
background: #fff7e6;
animation-name: term-blink;
animation-duration: 1s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.term__out {
margin-top: 4px;
opacity: 1;
transform: translateY(0);
}
.term__out .n {
color: #6ec8ff;
}
.term__out .c {
color: rgba(255, 247, 230, 0.72);
}
.term.is-demo .term__cmd {
animation-name: term-type;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.term.is-demo #term-out1 {
animation-name: term-out1;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.term.is-demo #term-out2 {
animation-name: term-out2;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes term-type {
0% {
width: 0ch;
animation-timing-function: steps(24, end);
}
44% {
width: 24ch;
animation-timing-function: steps(1, end);
}
86% {
width: 24ch;
animation-timing-function: steps(1, end);
}
100% {
width: 0ch;
}
}
@keyframes term-out1 {
0% {
opacity: 0;
transform: translateY(8px);
}
50% {
opacity: 0;
transform: translateY(8px);
animation-timing-function: ease-out;
}
64% {
opacity: 1;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
86% {
opacity: 1;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
transform: translateY(8px);
}
}
@keyframes term-out2 {
0% {
opacity: 0;
transform: translateY(8px);
}
66% {
opacity: 0;
transform: translateY(8px);
animation-timing-function: ease-out;
}
80% {
opacity: 1;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
86% {
opacity: 1;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
transform: translateY(8px);
}
}
@media (max-width: 340px) {
.term {
width: 100%;
}
.term__body {
font-size: 10px;
line-height: 16px;
padding: 8px;
}
.term__cursor {
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.term.is-demo .term__cmd,
.term.is-demo #term-out1,
.term.is-demo #term-out2,
.term__cursor {
animation: none !important;
}
.term.is-demo .term__cmd {
width: 24ch;
}
.term.is-demo #term-out1,
.term.is-demo #term-out2 {
opacity: 1;
transform: translateY(0);
}
}// 실제 동작: 사람이 터미널을 누르거나 Enter/Space 로 초점을 주면 실제 setTimeout 으로 다시 타이핑한다 (CSS 루프는 멈춘다)
(function () {
var CMD = 'npm i -D eslint prettier';
var root = document.getElementById('term');
var cmd = document.getElementById('term-cmd');
var out1 = document.getElementById('term-out1');
var out2 = document.getElementById('term-out2');
var timers = [];
function clear() { timers.forEach(function (t) { clearTimeout(t); }); timers = []; }
function show(el, on) { el.style.opacity = on ? '1' : '0'; el.style.transform = on ? 'translateY(0)' : 'translateY(8px)'; }
function play() {
root.classList.remove('is-demo');
clear();
cmd.textContent = '';
cmd.style.width = '0ch';
show(out1, false);
show(out2, false);
var i = 0;
(function tick() {
i += 1;
cmd.textContent = CMD.slice(0, i);
cmd.style.width = i + 'ch';
if (i < CMD.length) {
timers.push(setTimeout(tick, 36));
} else {
timers.push(setTimeout(function () { show(out1, true); }, 240));
timers.push(setTimeout(function () { show(out2, true); }, 520));
}
})();
}
root.addEventListener('click', play);
root.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); play(); }
});
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TerminalTypingCursor.module.scss";
export type TerminalTypingCursorProps = {
cmd?: string;
replayLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T | null };
const CMD = "npm i -D eslint prettier";
function TerminalIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" />
<path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z" />
</g>
</svg>
);
}
export default function TerminalTypingCursor({
cmd = CMD,
replayLabel = "Replay",
demo = true,
duration = 2000,
easing = "ease-out",
color = "#6ec8ff",
}: TerminalTypingCursorProps) {
const [live, setLive] = useState(demo);
const [typed, setTyped] = useState(cmd);
const [out1, setOut1] = useState(true);
const [out2, setOut2] = useState(true);
const timers = useRef<number[]>([]) as Box<number[]>;
useEffect(() => {
return () => {
(timers.current || []).forEach((t) => window.clearTimeout(t));
};
}, []);
function clear() {
(timers.current || []).forEach((t) => window.clearTimeout(t));
timers.current = [];
}
function play() {
setLive(false);
clear();
setTyped("");
setOut1(false);
setOut2(false);
let i = 0;
function tick() {
i += 1;
setTyped(cmd.slice(0, i));
if (i < cmd.length) {
const id = window.setTimeout(tick, 36);
(timers.current || (timers.current = [])).push(id);
} else {
const a = window.setTimeout(() => setOut1(true), 240);
const b = window.setTimeout(() => setOut2(true), 520);
timers.current = [...(timers.current || []), a, b];
}
}
tick();
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div
className={[styles.term, live ? styles.isDemo : ""].join(" ")}
style={vars}
role="button"
tabIndex={0}
aria-label={replayLabel}
onClick={play}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
play();
}
}}
>
<div className={styles.head}>
<TerminalIcon />
<span className={styles.name}>bash</span>
</div>
<div className={styles.body}>
<div className={styles.line}>
<span className={styles.prompt} aria-hidden="true">$</span>
<span className={styles.cmd} style={live ? undefined : { width: `${typed.length}ch` }}>{live ? cmd : typed}</span>
<span className={styles.cursor} aria-hidden="true" />
</div>
<div
className={[styles.out, styles.out1].join(" ")}
style={live ? undefined : { opacity: out1 ? 1 : 0, transform: out1 ? "translateY(0)" : "translateY(8px)" }}
>
<span className={styles.c}>added </span>
<span className={styles.n}>2 packages</span>
<span className={styles.c}> in 1.4s</span>
</div>
<div
className={[styles.out, styles.out2].join(" ")}
style={live ? undefined : { opacity: out2 ? 1 : 0, transform: out2 ? "translateY(0)" : "translateY(8px)" }}
>
<span className={styles.c}>audited </span>
<span className={styles.n}>128 packages</span>
<span className={styles.c}> in 2.1s</span>
</div>
</div>
</div>
);
}07 · 명령이 스스로 쳐지는 터미널 — 측정 누적면적 2.603% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
커서는 명령 글자와 별도 요소라 폭이 늘어나는 동안 저절로 줄 끝을 따라갑니다.
function play() {
cmd.textContent = '';
cmd.style.width = '0ch';
var i = 0;
(function tick() {
i += 1;
cmd.textContent = CMD.slice(0, i);
cmd.style.width = i + 'ch';
if (i < CMD.length) {
timers.push(setTimeout(tick, 36));
} else {
timers.push(setTimeout(function () { show(out1, true); }, 240));
timers.push(setTimeout(function () { show(out2, true); }, 520));
}
})();
}
08코드와 결과를 번갈아 보는 창
화면에서 일어나는 일
같은 창 안 두 겹 중 코드 쪽이 꺼지는 순간 결과 쪽이 opacity·visibility 로 한 프레임에 켜져, 두 장이 겹쳐 반투명하게 비치는 순간이 없습니다.

<main class="stage">
<div class="rps is-demo" id="rps">
<div class="rps__head">
<span class="rps__file">badge.css</span>
<div class="rps__tabs">
<button class="rps__tab is-on" id="rps-code-btn" type="button" aria-pressed="true" aria-label="코드 보기">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="m240 128l-48 40H64l-48-40l48-40h128Z" opacity=".2"/><path d="M69.12 94.15L28.5 128l40.62 33.85a8 8 0 1 1-10.24 12.29l-48-40a8 8 0 0 1 0-12.29l48-40a8 8 0 0 1 10.24 12.3m176 27.7l-48-40a8 8 0 1 0-10.24 12.3L227.5 128l-40.62 33.85a8 8 0 1 0 10.24 12.29l48-40a8 8 0 0 0 0-12.29m-82.39-89.37a8 8 0 0 0-10.25 4.79l-64 176a8 8 0 0 0 4.79 10.26A8.1 8.1 0 0 0 96 224a8 8 0 0 0 7.52-5.27l64-176a8 8 0 0 0-4.79-10.25"/></g></svg>
</button>
<button class="rps__tab" id="rps-eye-btn" type="button" aria-pressed="false" aria-label="결과 보기">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32"/></g></svg>
</button>
</div>
</div>
<div class="rps__stage">
<span class="rps__ptr" id="rps-ptr" aria-hidden="true"></span>
<pre class="rps__face rps__face--code" id="rps-code" aria-hidden="false"><code><span class="ln"><span class="k">.badge</span> <span class="c">{</span></span><span class="ln"> <span class="k">border-radius</span><span class="c">:</span> <span class="n">999px</span><span class="c">;</span></span><span class="ln"> <span class="k">background</span><span class="c">:</span> <span class="s">linear-gradient</span><span class="c">(#91a2c9, #bed4df);</span></span><span class="ln"><span class="c">}</span></span></code></pre>
<div class="rps__face rps__face--result" id="rps-result" aria-hidden="true">
<div class="rps__card" id="rps-card">
<span class="rps__badge" id="rps-badge">신규</span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// 구문 색 — 어두운 패널
$syn-key: $stage-orange;
$syn-str: $stage-yellow;
$syn-num: $subject-sky;
$syn-dim: rgba($subject-cream, .72);
$syn-txt: $subject-cream;
.rps {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.rps__head {
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
color: $syn-dim;
background: rgba(255, 247, 230, .08);
}
.rps__file { font-size: 11px; font-weight: 700; color: $syn-txt; }
.rps__tabs { display: flex; align-items: center; gap: $sp-1; margin-left: auto; }
.rps__tab {
display: inline-flex;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: $r-xs;
background: transparent;
color: $syn-dim;
opacity: .55;
cursor: pointer;
}
.rps__tab.is-on { opacity: 1; color: $syn-txt; background: rgba(255, 247, 230, .14); }
.rps__tab:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.rps__stage {
position: relative;
box-sizing: border-box;
height: 132px;
padding: $sp-3;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.rps__ptr {
position: absolute;
top: 6px;
right: 40px;
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255, 247, 230, .7);
box-shadow: 0 0 0 4px rgba(255, 247, 230, .18);
pointer-events: none;
}
.rps__face {
box-sizing: border-box;
position: absolute;
inset: $sp-3;
margin: 0;
padding: 0;
color: $syn-txt;
}
.rps__face--code { opacity: 1; visibility: visible; }
.rps__face--result { opacity: 0; visibility: hidden; }
.rps__face--code .n { color: $syn-num; }
.rps__face--code .s { color: $syn-str; }
.rps__face--code .c { color: $syn-dim; }
.ln { display: block; }
.rps__face--result { display: grid; place-items: center; }
.rps__card {
position: relative;
width: 160px;
height: 84px;
border-radius: $r-card;
background: linear-gradient($subject-blue, $subject-sky);
box-shadow: $shadow-press;
transform: scale(.94);
}
.rps__badge {
position: absolute;
top: -8px;
left: -8px;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $stage-orange;
color: $subject-cream;
font-size: 11px;
font-weight: 700;
opacity: 0;
transform: translateY(6px);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~14% 포인터가 eye 로 · 16% 컷 결과 면 · 18~40% 카드·배지 등장 · 44~68% 포인터 복귀 ·
// 70% 컷 코드 면(즉시 원문 그대로 보임) · 86~100% 정지
.rps.is-demo .rps__ptr {
animation-name: rps-ptr;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-code-btn {
animation-name: rps-tab-code;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-eye-btn {
animation-name: rps-tab-eye;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-code {
animation-name: rps-code;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-result {
animation-name: rps-result;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-card {
animation-name: rps-card;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-badge {
animation-name: rps-badge;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes rps-ptr {
0% { transform: translate(0, 0); }
14% { transform: translate(24px, 0); animation-timing-function: $easing; }
44% { transform: translate(24px, 0); }
68% { transform: translate(0, 0); animation-timing-function: $easing; }
100% { transform: translate(0, 0); }
}
@keyframes rps-tab-code {
0% { opacity: 1; }
16% { opacity: .55; animation-timing-function: steps(1, end); }
70% { opacity: 1; animation-timing-function: steps(1, end); }
100% { opacity: 1; }
}
@keyframes rps-tab-eye {
0% { opacity: .55; }
16% { opacity: 1; animation-timing-function: steps(1, end); }
70% { opacity: .55; animation-timing-function: steps(1, end); }
100% { opacity: .55; }
}
@keyframes rps-code {
0% { opacity: 1; visibility: visible; animation-timing-function: steps(1, end); }
16% { opacity: 0; visibility: hidden; animation-timing-function: steps(1, end); }
70% { opacity: 1; visibility: visible; animation-timing-function: steps(1, end); }
100% { opacity: 1; visibility: visible; }
}
@keyframes rps-result {
0% { opacity: 0; visibility: hidden; animation-timing-function: steps(1, end); }
16% { opacity: 1; visibility: visible; animation-timing-function: steps(1, end); }
70% { opacity: 0; visibility: hidden; animation-timing-function: steps(1, end); }
100% { opacity: 0; visibility: hidden; }
}
@keyframes rps-card {
0% { transform: scale(.94); }
18% { transform: scale(.94); animation-timing-function: $easing; }
40% { transform: scale(1); }
70% { transform: scale(1); }
100% { transform: scale(.94); }
}
@keyframes rps-badge {
0% { opacity: 0; transform: translateY(6px); }
18% { opacity: 0; transform: translateY(6px); animation-timing-function: $easing; }
40% { opacity: 1; transform: translateY(0); }
70% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(6px); }
}
@media (max-width: 340px) {
.rps { width: 100%; }
.rps__stage { font-size: 10px; line-height: 16px; padding: $sp-2; height: 124px; }
.rps__face { inset: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rps.is-demo .rps__ptr,
.rps.is-demo #rps-code-btn,
.rps.is-demo #rps-eye-btn,
.rps.is-demo #rps-code,
.rps.is-demo #rps-result,
.rps.is-demo #rps-card,
.rps.is-demo #rps-badge { animation: none !important; }
.rps.is-demo #rps-code { opacity: 1; visibility: visible; }
.rps.is-demo #rps-result { opacity: 0; visibility: hidden; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rps {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.rps__head {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 12px;
color: rgba(255, 247, 230, 0.72);
background: rgba(255, 247, 230, 0.08);
}
.rps__file {
font-size: 11px;
font-weight: 700;
color: #fff7e6;
}
.rps__tabs {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.rps__tab {
display: inline-flex;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: rgba(255, 247, 230, 0.72);
opacity: 0.55;
cursor: pointer;
}
.rps__tab.is-on {
opacity: 1;
color: #fff7e6;
background: rgba(255, 247, 230, 0.14);
}
.rps__tab:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.rps__stage {
position: relative;
box-sizing: border-box;
height: 132px;
padding: 12px;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
}
.rps__ptr {
position: absolute;
top: 6px;
right: 40px;
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.7);
box-shadow: 0 0 0 4px rgba(255, 247, 230, 0.18);
pointer-events: none;
}
.rps__face {
box-sizing: border-box;
position: absolute;
inset: 12px;
margin: 0;
padding: 0;
color: #fff7e6;
}
.rps__face--code {
opacity: 1;
visibility: visible;
}
.rps__face--result {
opacity: 0;
visibility: hidden;
}
.rps__face--code .n {
color: #6ec8ff;
}
.rps__face--code .s {
color: #ffd23f;
}
.rps__face--code .c {
color: rgba(255, 247, 230, 0.72);
}
.ln {
display: block;
}
.rps__face--result {
display: grid;
place-items: center;
}
.rps__card {
position: relative;
width: 160px;
height: 84px;
border-radius: 16px;
background: linear-gradient(#2f6df6, #6ec8ff);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: scale(0.94);
}
.rps__badge {
position: absolute;
top: -8px;
left: -8px;
padding: 4px 8px;
border-radius: 999px;
background: #ff4d1f;
color: #fff7e6;
font-size: 11px;
font-weight: 700;
opacity: 0;
transform: translateY(6px);
}
.rps.is-demo .rps__ptr {
animation-name: rps-ptr;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-code-btn {
animation-name: rps-tab-code;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-eye-btn {
animation-name: rps-tab-eye;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-code {
animation-name: rps-code;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-result {
animation-name: rps-result;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-card {
animation-name: rps-card;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.rps.is-demo #rps-badge {
animation-name: rps-badge;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes rps-ptr {
0% {
transform: translate(0, 0);
}
14% {
transform: translate(24px, 0);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
44% {
transform: translate(24px, 0);
}
68% {
transform: translate(0, 0);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
100% {
transform: translate(0, 0);
}
}
@keyframes rps-tab-code {
0% {
opacity: 1;
}
16% {
opacity: 0.55;
animation-timing-function: steps(1, end);
}
70% {
opacity: 1;
animation-timing-function: steps(1, end);
}
100% {
opacity: 1;
}
}
@keyframes rps-tab-eye {
0% {
opacity: 0.55;
}
16% {
opacity: 1;
animation-timing-function: steps(1, end);
}
70% {
opacity: 0.55;
animation-timing-function: steps(1, end);
}
100% {
opacity: 0.55;
}
}
@keyframes rps-code {
0% {
opacity: 1;
visibility: visible;
animation-timing-function: steps(1, end);
}
16% {
opacity: 0;
visibility: hidden;
animation-timing-function: steps(1, end);
}
70% {
opacity: 1;
visibility: visible;
animation-timing-function: steps(1, end);
}
100% {
opacity: 1;
visibility: visible;
}
}
@keyframes rps-result {
0% {
opacity: 0;
visibility: hidden;
animation-timing-function: steps(1, end);
}
16% {
opacity: 1;
visibility: visible;
animation-timing-function: steps(1, end);
}
70% {
opacity: 0;
visibility: hidden;
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
visibility: hidden;
}
}
@keyframes rps-card {
0% {
transform: scale(0.94);
}
18% {
transform: scale(0.94);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
40% {
transform: scale(1);
}
70% {
transform: scale(1);
}
100% {
transform: scale(0.94);
}
}
@keyframes rps-badge {
0% {
opacity: 0;
transform: translateY(6px);
}
18% {
opacity: 0;
transform: translateY(6px);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
40% {
opacity: 1;
transform: translateY(0);
}
70% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(6px);
}
}
@media (max-width: 340px) {
.rps {
width: 100%;
}
.rps__stage {
font-size: 10px;
line-height: 16px;
padding: 8px;
height: 124px;
}
.rps__face {
inset: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rps.is-demo .rps__ptr,
.rps.is-demo #rps-code-btn,
.rps.is-demo #rps-eye-btn,
.rps.is-demo #rps-code,
.rps.is-demo #rps-result,
.rps.is-demo #rps-card,
.rps.is-demo #rps-badge {
animation: none !important;
}
.rps.is-demo #rps-code {
opacity: 1;
visibility: visible;
}
.rps.is-demo #rps-result {
opacity: 0;
visibility: hidden;
}
}// 실제 동작: 버튼 두 개가 코드 면 / 결과 면을 서로 바꾼다. aria-pressed 가 실제로 갱신된다
(function () {
var root = document.getElementById('rps');
var codeBtn = document.getElementById('rps-code-btn');
var eyeBtn = document.getElementById('rps-eye-btn');
var codeFace = document.getElementById('rps-code');
var resultFace = document.getElementById('rps-result');
function setView(showResult) {
root.classList.remove('is-demo');
codeBtn.setAttribute('aria-pressed', String(!showResult));
eyeBtn.setAttribute('aria-pressed', String(showResult));
codeBtn.classList.toggle('is-on', !showResult);
eyeBtn.classList.toggle('is-on', showResult);
codeFace.style.opacity = showResult ? '0' : '1';
codeFace.style.visibility = showResult ? 'hidden' : 'visible';
resultFace.style.opacity = showResult ? '1' : '0';
resultFace.style.visibility = showResult ? 'visible' : 'hidden';
codeFace.setAttribute('aria-hidden', String(showResult));
resultFace.setAttribute('aria-hidden', String(!showResult));
}
codeBtn.addEventListener('click', function () { setView(false); });
eyeBtn.addEventListener('click', function () { setView(true); });
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ResultPreviewSplit.module.scss";
export type ResultPreviewSplitProps = {
fileName?: string;
codeLabel?: string;
resultLabel?: string;
badgeLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function CodeIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="m240 128l-48 40H64l-48-40l48-40h128Z" opacity=".2" />
<path d="M69.12 94.15L28.5 128l40.62 33.85a8 8 0 1 1-10.24 12.29l-48-40a8 8 0 0 1 0-12.29l48-40a8 8 0 0 1 10.24 12.3m176 27.7l-48-40a8 8 0 1 0-10.24 12.3L227.5 128l-40.62 33.85a8 8 0 1 0 10.24 12.29l48-40a8 8 0 0 0 0-12.29m-82.39-89.37a8 8 0 0 0-10.25 4.79l-64 176a8 8 0 0 0 4.79 10.26A8.1 8.1 0 0 0 96 224a8 8 0 0 0 7.52-5.27l64-176a8 8 0 0 0-4.79-10.25" />
</g>
</svg>
);
}
function EyeIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2" />
<path d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32" />
</g>
</svg>
);
}
export default function ResultPreviewSplit({
fileName = "badge.css",
codeLabel = "View code",
resultLabel = "View result",
badgeLabel = "New",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ResultPreviewSplitProps) {
const [live, setLive] = useState(demo);
const [showResult, setShowResult] = useState(false);
function setView(next: boolean) {
setLive(false);
setShowResult(next);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const codeStyle: CSSProperties | undefined = live
? undefined
: { opacity: showResult ? 0 : 1, visibility: showResult ? "hidden" : "visible" };
const resultStyle: CSSProperties | undefined = live
? undefined
: { opacity: showResult ? 1 : 0, visibility: showResult ? "visible" : "hidden" };
return (
<div className={[styles.rps, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div className={styles.head}>
<span className={styles.file}>{fileName}</span>
<div className={styles.tabs}>
<button
className={[styles.tab, styles.tabCode, !showResult ? styles.isOn : ""].join(" ")}
type="button"
aria-pressed={!showResult}
aria-label={codeLabel}
onClick={() => setView(false)}
>
<CodeIcon />
</button>
<button
className={[styles.tab, styles.tabEye, showResult ? styles.isOn : ""].join(" ")}
type="button"
aria-pressed={showResult}
aria-label={resultLabel}
onClick={() => setView(true)}
>
<EyeIcon />
</button>
</div>
</div>
<div className={styles.stage}>
<span className={styles.ptr} aria-hidden="true" />
<pre className={[styles.face, styles.faceCode].join(" ")} style={codeStyle} aria-hidden={showResult}>
<code>
<span className={styles.ln}><span className={styles.k}>.badge</span> <span className={styles.c}>{"{"}</span></span>
<span className={styles.ln}> <span className={styles.k}>border-radius</span><span className={styles.c}>:</span> <span className={styles.n}>999px</span><span className={styles.c}>;</span></span>
<span className={styles.ln}> <span className={styles.k}>background</span><span className={styles.c}>:</span> <span className={styles.s}>linear-gradient</span><span className={styles.c}>(#26f, #6cf);</span></span>
<span className={styles.ln}><span className={styles.c}>{"}"}</span></span>
</code>
</pre>
<div className={[styles.face, styles.faceResult].join(" ")} style={resultStyle} aria-hidden={!showResult}>
<div className={styles.card}>
<span className={styles.badge}>{badgeLabel}</span>
</div>
</div>
</div>
</div>
);
}08 · 코드와 결과를 번갈아 보는 창 — 측정 누적면적 11.172% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
지금 켜진 쪽은 버튼의 aria-pressed 에도 남아 두 버튼 중 무엇이 눌려 있는지 헷갈리지 않습니다.
function setView(showResult) {
root.classList.remove('is-demo');
codeBtn.setAttribute('aria-pressed', String(!showResult));
eyeBtn.setAttribute('aria-pressed', String(showResult));
codeFace.style.opacity = showResult ? '0' : '1';
codeFace.style.visibility = showResult ? 'hidden' : 'visible';
resultFace.style.opacity = showResult ? '1' : '0';
resultFace.style.visibility = showResult ? 'visible' : 'hidden';
codeFace.setAttribute('aria-hidden', String(showResult));
resultFace.setAttribute('aria-hidden', String(!showResult));
}
09낱말을 짚으면 뜻이 뜨는 인라인 코드
어떤 움직임인가
문장 속 border-radius, box-shadow 같은 낱말에는 점선 밑줄만 그어 두고, 올리거나 탭으로 초점을 주면 그 뜻을 적은 쪽지가 문단 아래에서 떠오릅니다.

<main class="stage">
<div class="tp is-demo" id="tp">
<div class="tp__head">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg>
<span class="tp__label">tips.md</span>
</div>
<div class="tp__body" id="tp-body">
<p class="tp__line">Round the corners with <button class="tok" id="tp-tok1" type="button" aria-describedby="tp-pop1" aria-label="설명 보기">border-radius</button>.</p>
<p class="tp__line">Lift the card using <button class="tok" id="tp-tok2" type="button" aria-describedby="tp-pop2" aria-label="설명 보기">box-shadow</button>.</p>
<pre class="tp__code"><code><span class="ln"><span class="k">border-radius</span><span class="c">:</span> <span class="n">16px</span><span class="c">;</span></span><span class="ln"><span class="k">box-shadow</span><span class="c">:</span> <span class="s">0 8px 16px</span> <span class="n">rgba(0,0,0,.18)</span><span class="c">;</span></span></code></pre>
<span class="tp__ptr" id="tp-ptr" aria-hidden="true"></span>
<div class="tp__pop" id="tp-pop1" role="tooltip">모서리를 둥글게 깎는 반지름입니다.</div>
<div class="tp__pop" id="tp-pop2" role="tooltip">떠 있는 느낌을 주는 그림자입니다.</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 구문 색 — 밝은 패널
$syn-key: $subject-orange-deep;
$syn-str: $subject-blue-deep;
$syn-num: $error;
$syn-dim: rgba($subject-ink, .72);
$syn-txt: $subject-ink;
.tp {
box-sizing: border-box;
width: 288px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
color: $subject-ink;
}
.tp__head {
display: flex;
align-items: center;
gap: $sp-2;
height: 28px;
padding: 0 $sp-3;
color: rgba(23, 20, 26, .6);
background: rgba(23, 20, 26, .05);
}
.tp__label { font-size: 11px; font-weight: 700; color: $subject-ink; }
.tp__body {
position: relative;
box-sizing: border-box;
padding: $sp-3;
padding-bottom: $sp-10; // 함정 7 — 팝오버는 position:absolute 라 뿌리 높이에 안 잡힌다. 미리 확보
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 18px;
}
.tp__line { margin: 0 0 $sp-1 0; }
.tp__line:last-of-type { margin-bottom: $sp-2; }
.tok {
display: inline;
padding: 0 $sp-1;
border: 0;
border-radius: $r-xs;
background: transparent;
color: $syn-key;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 700;
text-decoration: underline dotted;
text-underline-offset: 3px;
cursor: pointer;
}
.tok.is-on { background: rgba(47, 109, 246, .14); color: $subject-blue-deep; text-decoration: none; }
.tok:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.tp__code {
box-sizing: border-box;
margin: 0;
padding: $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .04);
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
color: $syn-txt;
}
.tp__code .k { color: $syn-key; }
.tp__code .s { color: $syn-str; }
.tp__code .n { color: $syn-num; }
.tp__code .c { color: $syn-dim; }
.ln { display: block; }
.tp__ptr {
position: absolute;
top: 26px;
left: 221px;
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(23, 20, 26, .55);
box-shadow: 0 0 0 4px rgba(23, 20, 26, .12);
pointer-events: none;
}
.tp__pop {
box-sizing: border-box;
position: absolute;
left: $sp-3;
right: $sp-3;
bottom: $sp-3;
padding: $sp-2;
border-radius: $r-sm;
background: $subject-blue;
color: $subject-cream;
font-size: 12px;
line-height: 16px;
font-weight: 700;
box-shadow: $shadow-press;
opacity: 0;
visibility: hidden;
transform: translateY(6px) scale(.96);
}
// 정적 상태 — 사람이 눌러/짚어 열면 바로 보인다 (함정 10)
.tp__pop.is-open { opacity: 1; visibility: visible; transform: translateY(0) scale(1); }
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~14% 포인터가 첫 tok · 16% 팝오버A 등장+tok1 켬 · 34~48% 포인터가 둘째 tok ·
// 50% 컷 A→B 교체(+tok2 켬, tok1 끔) · 52~64% B 정착 · 66~84% 포인터 복귀 + B 서서히 사라짐(tok2 끔) · 86~100% 정지
.tp.is-demo #tp-ptr {
animation-name: tp-ptr;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-tok1 {
animation-name: tp-tok1-on;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-tok2 {
animation-name: tp-tok2-on;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-pop1 {
animation-name: tp-pop1;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-pop2 {
animation-name: tp-pop2;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes tp-ptr {
0% { transform: translate(0, 0); }
14% { transform: translate(0, 0); animation-timing-function: $easing; }
34% { transform: translate(0, 0); animation-timing-function: $easing; }
48% { transform: translate(-46px, 22px); animation-timing-function: $easing; }
66% { transform: translate(-46px, 22px); animation-timing-function: $easing; }
84% { transform: translate(0, 0); }
100% { transform: translate(0, 0); }
}
@keyframes tp-tok1-on {
0% { background: transparent; color: $syn-key; }
16% { background: transparent; color: $syn-key; animation-timing-function: steps(1, end); }
18% { background: rgba(47, 109, 246, .14); color: $subject-blue; animation-timing-function: steps(1, end); }
// 첫 낱말은 둘째 낱말이 켜지는 50% 에 **그 프레임에** 꺼진다.
// 66% 까지 남겨 두면 두 낱말이 동시에 켜져 무엇을 설명 중인지 흐려진다 (poster 육안 실측)
49% { background: rgba(47, 109, 246, .14); color: $subject-blue; animation-timing-function: steps(1, end); }
50% { background: transparent; color: $syn-key; animation-timing-function: steps(1, end); }
100% { background: transparent; color: $syn-key; }
}
@keyframes tp-tok2-on {
0% { background: transparent; color: $syn-key; }
49% { background: transparent; color: $syn-key; animation-timing-function: steps(1, end); }
50% { background: rgba(47, 109, 246, .14); color: $subject-blue; animation-timing-function: steps(1, end); }
83% { background: rgba(47, 109, 246, .14); color: $subject-blue; animation-timing-function: steps(1, end); }
84% { background: transparent; color: $syn-key; }
100% { background: transparent; color: $syn-key; }
}
// 쪽지 두 장은 **같은 자리**에 겹쳐 있다. A→B 교체는 글자만 바뀌므로 변화 면적이 작고,
// 등장(18%)과 퇴장이 훨씬 크다. 그래서 등장은 한 프레임 컷으로 크게 만들고,
// 퇴장은 76~88% 에 걸쳐 아래로 **미끄러져 잘려 나가게** 해 프레임당 변화를 잘게 쪼갠다.
// (opacity 로 빼면 반투명 유령 프레임이 생기고, 컷으로 빼면 그 한 장이 poster 가 된다)
@keyframes tp-pop1 {
0% { opacity: 0; visibility: hidden; transform: translateY(44px); animation-timing-function: steps(1, end); }
17% { opacity: 0; visibility: hidden; transform: translateY(44px); animation-timing-function: steps(1, end); }
18% { opacity: 1; visibility: visible; transform: translateY(0); animation-timing-function: steps(1, end); }
49% { opacity: 1; visibility: visible; transform: translateY(0); animation-timing-function: steps(1, end); }
50% { opacity: 0; visibility: hidden; transform: translateY(44px); animation-timing-function: steps(1, end); }
100% { opacity: 0; visibility: hidden; transform: translateY(44px); }
}
@keyframes tp-pop2 {
0% { opacity: 0; visibility: hidden; transform: translateY(44px); animation-timing-function: steps(1, end); }
49% { opacity: 0; visibility: hidden; transform: translateY(44px); animation-timing-function: steps(1, end); }
50% { opacity: 1; visibility: visible; transform: translateY(0); animation-timing-function: steps(1, end); }
76% { opacity: 1; visibility: visible; transform: translateY(0); animation-timing-function: $easing; }
88% { opacity: 1; visibility: visible; transform: translateY(44px); animation-timing-function: steps(1, end); }
100% { opacity: 0; visibility: hidden; transform: translateY(44px); }
}
@media (max-width: 340px) {
.tp { width: 100%; }
.tp__body { font-size: 11px; line-height: 16px; padding: $sp-2; padding-bottom: $sp-10; }
.tp__code { font-size: 10px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.tp.is-demo #tp-ptr,
.tp.is-demo #tp-tok1,
.tp.is-demo #tp-tok2,
.tp.is-demo #tp-pop1,
.tp.is-demo #tp-pop2 { animation: none !important; }
.tp.is-demo #tp-pop1,
.tp.is-demo #tp-pop2 { opacity: 0; visibility: hidden; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tp {
box-sizing: border-box;
width: 288px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
color: #17141a;
}
.tp__head {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 12px;
color: rgba(23, 20, 26, 0.6);
background: rgba(23, 20, 26, 0.05);
}
.tp__label {
font-size: 11px;
font-weight: 700;
color: #17141a;
}
.tp__body {
position: relative;
box-sizing: border-box;
padding: 12px;
padding-bottom: 40px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 18px;
}
.tp__line {
margin: 0 0 4px 0;
}
.tp__line:last-of-type {
margin-bottom: 8px;
}
.tok {
display: inline;
padding: 0 4px;
border: 0;
border-radius: 4px;
background: transparent;
color: #c93000;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 700;
text-decoration: underline dotted;
text-underline-offset: 3px;
cursor: pointer;
}
.tok.is-on {
background: rgba(47, 109, 246, 0.14);
color: #0a4fe8;
text-decoration: none;
}
.tok:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.tp__code {
box-sizing: border-box;
margin: 0;
padding: 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.04);
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 18px;
color: #17141a;
}
.tp__code .k {
color: #c93000;
}
.tp__code .s {
color: #0a4fe8;
}
.tp__code .n {
color: #c2361a;
}
.tp__code .c {
color: rgba(23, 20, 26, 0.72);
}
.ln {
display: block;
}
.tp__ptr {
position: absolute;
top: 26px;
left: 221px;
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.55);
box-shadow: 0 0 0 4px rgba(23, 20, 26, 0.12);
pointer-events: none;
}
.tp__pop {
box-sizing: border-box;
position: absolute;
left: 12px;
right: 12px;
bottom: 12px;
padding: 8px;
border-radius: 8px;
background: #2f6df6;
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 700;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
opacity: 0;
visibility: hidden;
transform: translateY(6px) scale(0.96);
}
.tp__pop.is-open {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.tp.is-demo #tp-ptr {
animation-name: tp-ptr;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-tok1 {
animation-name: tp-tok1-on;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-tok2 {
animation-name: tp-tok2-on;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-pop1 {
animation-name: tp-pop1;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo #tp-pop2 {
animation-name: tp-pop2;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes tp-ptr {
0% {
transform: translate(0, 0);
}
14% {
transform: translate(0, 0);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
34% {
transform: translate(0, 0);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
48% {
transform: translate(-46px, 22px);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
66% {
transform: translate(-46px, 22px);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
84% {
transform: translate(0, 0);
}
100% {
transform: translate(0, 0);
}
}
@keyframes tp-tok1-on {
0% {
background: transparent;
color: #c93000;
}
16% {
background: transparent;
color: #c93000;
animation-timing-function: steps(1, end);
}
18% {
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
animation-timing-function: steps(1, end);
}
49% {
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
animation-timing-function: steps(1, end);
}
50% {
background: transparent;
color: #c93000;
animation-timing-function: steps(1, end);
}
100% {
background: transparent;
color: #c93000;
}
}
@keyframes tp-tok2-on {
0% {
background: transparent;
color: #c93000;
}
49% {
background: transparent;
color: #c93000;
animation-timing-function: steps(1, end);
}
50% {
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
animation-timing-function: steps(1, end);
}
83% {
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
animation-timing-function: steps(1, end);
}
84% {
background: transparent;
color: #c93000;
}
100% {
background: transparent;
color: #c93000;
}
}
@keyframes tp-pop1 {
0% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
animation-timing-function: steps(1, end);
}
17% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
animation-timing-function: steps(1, end);
}
18% {
opacity: 1;
visibility: visible;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
49% {
opacity: 1;
visibility: visible;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
50% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
}
}
@keyframes tp-pop2 {
0% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
animation-timing-function: steps(1, end);
}
49% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
animation-timing-function: steps(1, end);
}
50% {
opacity: 1;
visibility: visible;
transform: translateY(0);
animation-timing-function: steps(1, end);
}
76% {
opacity: 1;
visibility: visible;
transform: translateY(0);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
88% {
opacity: 1;
visibility: visible;
transform: translateY(44px);
animation-timing-function: steps(1, end);
}
100% {
opacity: 0;
visibility: hidden;
transform: translateY(44px);
}
}
@media (max-width: 340px) {
.tp {
width: 100%;
}
.tp__body {
font-size: 11px;
line-height: 16px;
padding: 8px;
padding-bottom: 40px;
}
.tp__code {
font-size: 10px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.tp.is-demo #tp-ptr,
.tp.is-demo #tp-tok1,
.tp.is-demo #tp-tok2,
.tp.is-demo #tp-pop1,
.tp.is-demo #tp-pop2 {
animation: none !important;
}
.tp.is-demo #tp-pop1,
.tp.is-demo #tp-pop2 {
opacity: 0;
visibility: hidden;
}
}// real JS: hover/focus/click shows the matching popover, Esc closes it
(function () {
var root = document.getElementById('tp');
var tok1 = document.getElementById('tp-tok1');
var tok2 = document.getElementById('tp-tok2');
var pop1 = document.getElementById('tp-pop1');
var pop2 = document.getElementById('tp-pop2');
function reset() {
root.classList.remove('is-demo');
tok1.classList.remove('is-on');
tok2.classList.remove('is-on');
pop1.classList.remove('is-open');
pop2.classList.remove('is-open');
}
function open(tok, pop) {
reset();
tok.classList.add('is-on');
pop.classList.add('is-open');
}
tok1.addEventListener('pointerenter', function () { open(tok1, pop1); });
tok1.addEventListener('focus', function () { open(tok1, pop1); });
tok1.addEventListener('click', function () { open(tok1, pop1); });
tok2.addEventListener('pointerenter', function () { open(tok2, pop2); });
tok2.addEventListener('focus', function () { open(tok2, pop2); });
tok2.addEventListener('click', function () { open(tok2, pop2); });
root.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { reset(); }
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./InlineTokenPopover.module.scss";
export type InlineTokenPopoverProps = {
fileName?: string;
describeLabel?: string;
pop1Text?: string;
pop2Text?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
function InfoIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12" />
</g>
</svg>
);
}
export default function InlineTokenPopover({
fileName = "tips.md",
describeLabel = "Show description",
pop1Text = "The radius that rounds off the corners.",
pop2Text = "The shadow that gives a floating feel.",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: InlineTokenPopoverProps) {
const [live, setLive] = useState(demo);
const [openTok, setOpenTok] = useState<0 | 1 | 2>(0);
function open(i: 1 | 2) {
setLive(false);
setOpenTok(i);
}
function reset() {
setOpenTok(0);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const tok1Style: CSSProperties | undefined = live
? undefined
: openTok === 1
? { background: "rgba(47,109,246,.14)", color: "var(--color)" }
: undefined;
const tok2Style: CSSProperties | undefined = live
? undefined
: openTok === 2
? { background: "rgba(47,109,246,.14)", color: "var(--color)" }
: undefined;
const pop1Style: CSSProperties | undefined = live
? undefined
: { opacity: openTok === 1 ? 1 : 0, visibility: openTok === 1 ? "visible" : "hidden", transform: openTok === 1 ? "translateY(0) scale(1)" : "translateY(6px) scale(.96)" };
const pop2Style: CSSProperties | undefined = live
? undefined
: { opacity: openTok === 2 ? 1 : 0, visibility: openTok === 2 ? "visible" : "hidden", transform: openTok === 2 ? "translateY(0) scale(1)" : "translateY(6px) scale(.96)" };
return (
<div
className={[styles.tp, live ? styles.isDemo : ""].join(" ")}
style={vars}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Escape") { e.preventDefault(); reset(); }
}}
>
<div className={styles.head}>
<InfoIcon />
<span className={styles.label}>{fileName}</span>
</div>
<div className={styles.body}>
<p className={styles.line}>
Round the corners with{" "}
<button
className={[styles.tok, styles.tok1, openTok === 1 ? styles.isOn : ""].join(" ")}
type="button"
aria-describedby="tp-pop1"
aria-label={describeLabel}
style={tok1Style}
onPointerEnter={() => open(1)}
onFocus={() => open(1)}
onClick={() => open(1)}
>
border-radius
</button>
.
</p>
<p className={styles.line}>
Lift the card using{" "}
<button
className={[styles.tok, styles.tok2, openTok === 2 ? styles.isOn : ""].join(" ")}
type="button"
aria-describedby="tp-pop2"
aria-label={describeLabel}
style={tok2Style}
onPointerEnter={() => open(2)}
onFocus={() => open(2)}
onClick={() => open(2)}
>
box-shadow
</button>
.
</p>
<pre className={styles.code}><code><span className={styles.ln}><span className={styles.k}>border-radius</span><span className={styles.c}>:</span> <span className={styles.n}>16px</span><span className={styles.c}>;</span></span><span className={styles.ln}><span className={styles.k}>box-shadow</span><span className={styles.c}>:</span> <span className={styles.s}>0 8px 16px</span> <span className={styles.n}>rgba(0,0,0,.18)</span><span className={styles.c}>;</span></span></code></pre>
<span className={styles.ptr} aria-hidden="true" />
<div id="tp-pop1" className={[styles.pop, styles.pop1, openTok === 1 ? styles.isOpen : ""].join(" ")} style={pop1Style} role="tooltip">
{pop1Text}
</div>
<div id="tp-pop2" className={[styles.pop, styles.pop2, openTok === 2 ? styles.isOpen : ""].join(" ")} style={pop2Style} role="tooltip">
{pop2Text}
</div>
</div>
</div>
);
}09 · 낱말을 짚으면 뜻이 뜨는 인라인 코드 — 측정 누적면적 10.227% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
쪽지 두 장은 같은 자리에 겹쳐 있다가 짚은 낱말이 바뀌면 글자만 컷으로 갈아 끼웁니다.
.tp__body {
position: relative;
padding: $sp-3;
padding-bottom: $sp-10;
}
.tok {
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.tp__pop {
position: absolute;
left: $sp-3; right: $sp-3; bottom: $sp-3;
opacity: 0;
visibility: hidden;
}
.tp__pop.is-open { opacity: 1; visibility: visible; }
어디서 깨지는가 — 함정
02의 탭 인디케이터 폭은 짐작이 아니라 Playwright 로 getBoundingClientRect 를 세 탭 각각에 대고 잰 값입니다. CSS 탭이 기준(43.2px)이고, 그 오른쪽에 SCSS 탭이 43.2px 지점에서 49.38px 폭으로, Tailwind 탭이 92.58px 지점에서 67.05px 폭으로 이어 붙습니다. 이 세 숫자를 그대로 translateX·scaleX 키프레임에 넣지 않으면 막대가 글자보다 짧거나 길게 걸칩니다.
08의 코드·결과 전환 창은 카드 전체에 overflow: hidden 이 걸려 있어 모서리가 둥글게 잘립니다. 문제는 이 규칙이 안쪽 코드 줄에도 그대로 적용된다는 점인데, 무대 높이(132px)를 넘는 줄은 스크롤바 없이 조용히 잘려 나가고 문서 전체 폭·높이는 그대로 안 넘치므로 자동 검사로는 안 걸립니다. poster.png 를 직접 눈으로 열어 넷째 줄까지 다 보이는지 확인해야 잡히는 함정입니다.
05는 원래 창이 아직 펼쳐진 63% 지점에서 버튼 라벨을 「더 보기」로 되돌렸는데, 그러면 화면에는 코드가 다 보이는데 버튼만 접혀 있다고 화면이 거짓말을 하는 순간이 생겼습니다. 라벨과 화살표를 접힘이 거의 끝나는 84%로 옮겨 grid-template-rows 가 0fr에 가까워지는 시점과 맞춘 뒤에야 없어졌습니다.
09의 설명 쪽지는 position: absolute 로 띄워 두어 부모 높이 계산에 안 잡힙니다. 그래서 본문 아래에 쪽지 한 장 높이만큼 padding-bottom 을 미리 비워 두지 않으면, 좁은 화면에서 쪽지가 카드 바깥으로 넘칩니다. 창 안 글자가 언어마다 길어질 때를 대비하려면 홈페이지 다국어 처리 검수의 점검이 앞서야 합니다. 압축 파일 비밀번호는 8j8vmbbq 이며, 아홉 벌 모두 같은 이유로 좁은 화면 규칙을 한 번씩 다시 확인했습니다.
접근성
아홉 개 모두 prefers-reduced-motion: reduce 에서 자동 루프만 꺼지고, 그 설정에서 화면에 남는 정지 상태는 항목마다 다릅니다. 어떤 값이 정확히 무엇을 끄는지는 MDN 문서에 정리되어 있습니다. 같은 갈래의 다른 화면은 버튼 상태 피드백과 CSS 탭 메뉴 전환에도 있고, 코드처럼 글을 담는 화면은 본문 카테고리에 모여 있습니다.
| 항목 | reduced-motion 정지 화면 | 키보드 | 낭독기 |
|---|---|---|---|
| 01 복사 버튼 | "복사됨" 라벨로 고정, 커서 숨김 | Tab · Enter | aria-live로 복사됨 통지 |
| 02 언어 탭 | CSS 탭 그대로 고정, 순환 없음 | 방향키로 탭 전환 | aria-selected · role=tablist |
| 03 줄 번호 | 짚은 줄 배경만 남고 쪽지·커서 숨김 | Tab으로 짚은 줄에 초점 | aria-describedby로 쪽지 연결 |
| 04 diff | 접힌 줄 그대로, 추가·삭제 띠는 이미 다 그려짐 | 접기 버튼 Tab · Enter | aria-expanded로 펼침 여부 |
| 05 접고 펴기 | 접힌 「더 보기」 상태로 고정 | 버튼 Tab · Enter | aria-expanded · 라벨 텍스트 |
| 06 창 머리 | 두 번째 탭(style.css)이 선택된 채로 고정 | 방향키로 탭 전환 | aria-selected + 머리 파일명 동기화 |
| 07 터미널 | 명령·출력 두 줄 전부 표시, 커서 깜빡임만 정지 | Enter · Space로 재생 | role="button" aria-label="다시 재생" |
| 08 코드·결과 전환 | 코드 면 고정, 결과 면 숨김 | 버튼 Tab · Enter | aria-pressed로 켜진 면 표시 |
| 09 인라인 팝오버 | 쪽지 둘 다 숨김, 밑줄만 남음 | Tab으로 낱말 초점, Esc로 닫기 | aria-describedby로 쪽지 연결 |
FAQ
코드 하이라이트 라이브러리 없이 색을 칠하는 게 가능한가요?
여기 아홉 개는 전부 <span class="k">, <span class="s"> 처럼 손으로 나눠 놓은 조각에 색을 입힌 것이라 라이브러리가 없어도 됩니다. 다만 이 방식은 예제가 고정돼 있을 때만 통하고, 사용자가 아무 코드나 붙여 넣어 실시간으로 칠해야 하는 화면이라면 Prism이나 Shiki 같은 전용 도구가 낫습니다. 보여 주는 데서 그치지 않고 고치는 창까지 가려면 코드 에디터 만들기가 다음 단계입니다.
복사 버튼의 navigator.clipboard 가 모든 브라우저에서 되나요?
writeText 는 HTTPS 같은 보안 컨텍스트에서만 열리고, 지원 범위는 caniuse.com 에서 브라우저별로 확인할 수 있습니다. 01의 코드는 navigator.clipboard 자체가 없는 환경이면 if 문에서 조용히 넘어가도록 짜여 있어, 지원하지 않는 브라우저에서도 버튼이 에러로 멈추지는 않습니다.
이 아홉 개를 React로 옮기면 뭐가 달라지나요?
zip 안에는 지금 본 바닐라 판과 같은 동작의 React 판이 함께 들어 있어, classList.toggle 자리가 useState 로, textContent 를 바꾸던 자리가 상태값을 그대로 렌더링하는 자리로 바뀝니다. aria-selected·aria-expanded 같은 접근성 속성은 이름과 값이 그대로 유지되므로 스크린리더 쪽 동작은 옮긴 뒤에도 같습니다.









