Code Editor JavaScript: 9 UI Parts, No Library
Code editor javascript is the plain way to get coloring, line numbers, and closing brackets into a page: nine parts, no library, just a textarea and a
01 editor-live-syntax-highlight
02 editor-line-number-gutter
03 editor-auto-close-brackets
04 editor-tab-indent-selection
05 editor-minimap-canvas
06 editor-find-replace-bar
07 editor-autocomplete-popup
08 editor-lint-error-underline
09 editor-theme-switcher
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Live syntax highlight
- 02 Line number gutter
- 03 Auto-close brackets
- 04 Tab indent and outdent
- 05 Canvas minimap
- 06 Find and replace bar
- 07 Caret autocomplete popup
- 08 Squiggly error underline
- 09 Editor theme switcher
The order is not popularity — it is the order in which one input box turns into an editor. Color lands on the glyphs first (01), numbers line up beside them (02), brackets start following your hands (03), and a selection learns to move as a block (04). Once the file is longer than the box, you need a picture of the whole thing (05) and a way to reach a string inside it (06). Then the box starts answering back: it finishes the word you began (07), underlines what will not parse (08), and finally changes clothes for the hours you actually work (09). Each one is a text editor using html css js and nothing else — the real input is always a <textarea>, and the color is painted by a <pre> sitting underneath it. Next door in the same embed pillar sit Terminal UI Design: 9 Copy-Paste CLI Parts, where command output rather than input is the subject, and i18n JSON Checks: 9 Ways to Catch Missing Keys, which inspects language-pack JSON instead of code.
01Live syntax highlight
A textarea with transparent glyphs sits over a <pre> in identical monospace metrics, and every keystroke repaints the layer underneath — the code input a docs site can ship.

<main class="stage">
<div class="sh is-demo" id="sh">
<div class="sh__bar">
<span class="sh__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 48v160H40V48Z" opacity=".2"/><path d="m69.12 94.15l-2.63-2.62a8 8 0 0 1 0-11.32l32-32a8 8 0 0 1 11.31 11.32L83.31 88l26.5 26.5a8 8 0 0 1-11.31 11.31Zm117.76 67.7l2.63 2.62a8 8 0 0 0 0-11.32l-32-32a8 8 0 0 0-11.31 11.32L172.69 168l-26.5 26.5a8 8 0 0 0 11.31 11.31Z"/><path d="M168 42.15a8 8 0 0 0-9.85 5.53l-48 168a8 8 0 0 0 5.53 9.85a8.5 8.5 0 0 0 2.16.3a8 8 0 0 0 7.69-5.8l48-168a8 8 0 0 0-5.53-9.88"/></g></svg></span>
<span class="sh__name">style.scss</span>
<span class="sh__count" id="count">4 colors</span>
</div>
<div class="sh__wrap" id="wrap">
<pre class="sh__hl" id="hl" aria-hidden="true"></pre>
<span class="sh__sweep" aria-hidden="true"></span>
<textarea class="sh__ta" id="ta" spellcheck="false" autocomplete="off" aria-label="Code input">/* card rise */
.card {
--lift: 8px;
content: "rise";
color: #fff7e6;
transition: all 220ms ease;
}</textarea>
</div>
<p class="sh__note">Type and the same spot is recolored</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 실시간 구문 강조 — 글자를 투명하게 만든 textarea 를 같은 자리·같은 서체의 pre 위에 얹는다.
// 입력이 들어오면 JS 가 pre 만 다시 칠하고, 스크롤은 두 겹이 같은 값으로 움직인다.
// 두 겹이 어긋나지 않으려면 서체·크기·행간·패딩·탭 폭·줄바꿈 규칙이 글자 단위로 같아야 한다 —
// 그래서 아래 `%layer` 를 두 요소가 함께 쓴다.
// 격자 미리보기(.is-demo)는 줄을 steps(1, end) 로 한 줄씩 켜고 마지막 줄만 clip-path 로 타이핑한다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $chart-3;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$dim: #beb7ad;
$fs: clamp(11px, 3vw, 13px);
$lh: 20px;
@include stage($stage-ink);
.sh {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .05);
}
// ── 파일 이름 줄
.sh__bar { display: flex; align-items: center; gap: $sp-2; }
.sh__ico { display: grid; place-items: center; color: $color; font-size: 15px; }
.sh__name {
flex: 1 1 auto; color: $subject-cream;
font-family: $mono; font-weight: 700; font-size: 12px; line-height: 1.4;
}
.sh__count {
flex: 0 0 auto; padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba(43, 157, 116, .22); color: $subject-cream;
font-family: $mono; font-weight: 700; font-size: 11px; line-height: 1.4;
}
// ── 두 겹이 겹치는 자리. 한 격자 칸에 pre 와 textarea 를 포갠다
.sh__wrap { position: relative; display: grid; height: $lh * 7 + $sp-4; }
.sh__wrap > * { grid-area: 1 / 1; }
%layer {
box-sizing: border-box; margin: 0; padding: $sp-2;
border: 0; border-radius: $r-sm;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
letter-spacing: 0; tab-size: 2;
white-space: pre; overflow: auto; overscroll-behavior: contain;
}
.sh__hl {
@extend %layer;
background: rgba(0, 0, 0, .28); color: $subject-cream;
pointer-events: none;
}
.sh__ln { display: block; }
.t--c { color: $dim; }
.t--s { color: $stage-yellow; }
.t--n { color: $subject-sky; }
.t--k { color: $color; }
// 글자는 투명하고 커서만 남긴다 — 색은 아래 pre 가 그린다
.sh__ta {
@extend %layer;
background: transparent; color: transparent; caret-color: $stage-yellow;
resize: none;
-webkit-text-fill-color: transparent;
}
.sh__ta::selection { background: rgba(255, 210, 63, .32); }
.sh__ta:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.sh__note {
margin: 0; color: $dim;
font-weight: 500; font-size: 11px; line-height: 1.4;
}
// 미리보기에서만 보이는 칠 띠. 줄이 다 켜진 뒤 한 번 지나가며 "다시 칠한다" 를 크게 보여준다.
// 정적 경로에서는 opacity 0 이라 실제 편집을 가리지 않는다
.sh__sweep {
align-self: center; justify-self: start;
width: 120px; height: 72px; border-radius: $r-sm;
background: linear-gradient(90deg, rgba(43, 157, 116, 0), rgba(43, 157, 116, .40), rgba(43, 157, 116, 0));
opacity: 0; pointer-events: none;
}
// ── 격자 미리보기 루프 (2초)
// 줄 1~6 은 0·10·20·30·40·50% 에 컷으로 켜지고, 마지막 줄은 52~86% 에 clip-path 로 타이핑된다.
// 켜짐은 steps(1, end) 라 반투명 유령 프레임이 없고, 되돌림은 루프 경계(0%)에서만 일어난다.
.sh.is-demo .sh__ln {
animation-name: shLine;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.sh.is-demo .sh__ln:nth-child(1) { animation-delay: 0s; }
.sh.is-demo .sh__ln:nth-child(2) { animation-delay: .2s; }
.sh.is-demo .sh__ln:nth-child(3) { animation-delay: .4s; }
.sh.is-demo .sh__ln:nth-child(4) { animation-delay: .6s; }
.sh.is-demo .sh__ln:nth-child(5) { animation-delay: .8s; }
.sh.is-demo .sh__ln:nth-child(6) { animation-delay: 1s; }
.sh.is-demo .sh__ln:nth-child(7) {
animation-name: shType; animation-timing-function: steps(12, end); animation-delay: 1.04s;
}
// 강조 칩은 줄이 다 켜진 뒤 한 번 부풀어 "다시 칠했다" 를 알린다 (자기 중심에서 — blog-demo 규칙 8)
.sh.is-demo .sh__count {
animation-name: shPulse; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
transform-origin: 50% 50%;
}
.sh.is-demo .sh__sweep {
animation-name: shSweep; animation-duration: $dur-loop; animation-timing-function: linear;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes shSweep {
0%, 54% { opacity: 0; transform: translateX(-120px); }
56% { opacity: 1; transform: translateX(-120px); }
88% { opacity: 1; transform: translateX(400px); }
90%, 100% { opacity: 0; transform: translateX(400px); }
}
// steps(1, end) 는 구간 **끝**에서만 값을 바꾼다 — 0%→100% 한 구간으로 적으면 2초 내내 0 이다.
// 그래서 3% 에 점프 지점을 따로 둔다 (실측 2026-09-13: 첫 판은 poster 가 빈 화면이었다)
@keyframes shLine {
0% { opacity: 0; }
3%, 100% { opacity: 1; }
}
@keyframes shType {
0% { clip-path: inset(0 100% 0 0); }
48% { clip-path: inset(0 0 0 0); }
100% { clip-path: inset(0 0 0 0); }
}
@keyframes shPulse {
0%, 56% { transform: scale(1); }
64% { transform: scale(1.22); }
76%, 100% { transform: scale(1); }
}
// ── 좁은 화면: 행간과 여백을 줄여 무대 안에 들어오게 한다
@media (max-width: 340px) {
.sh { gap: $sp-1; padding: $sp-2; }
.sh__wrap { height: 16px * 6 + $sp-2; }
.sh__hl, .sh__ta { font-size: 11px; line-height: 16px; padding: $sp-1; }
.sh__note { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sh.is-demo .sh__ln,
.sh.is-demo .sh__sweep,
.sh.is-demo .sh__count {
animation: none !important;
opacity: 1 !important;
clip-path: none !important;
transform: none !important;
}
}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);
}
.sh {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
}
.sh__bar {
display: flex;
align-items: center;
gap: 8px;
}
.sh__ico {
display: grid;
place-items: center;
color: #2b9d74;
font-size: 15px;
}
.sh__name {
flex: 1 1 auto;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
}
.sh__count {
flex: 0 0 auto;
padding: 4px 8px;
border-radius: 999px;
background: rgba(43, 157, 116, 0.22);
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
}
.sh__wrap {
position: relative;
display: grid;
height: 156px;
}
.sh__wrap > * {
grid-area: 1/1;
}
.sh__ta, .sh__hl {
box-sizing: border-box;
margin: 0;
padding: 8px;
border: 0;
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
letter-spacing: 0;
tab-size: 2;
white-space: pre;
overflow: auto;
overscroll-behavior: contain;
}
.sh__hl {
background: rgba(0, 0, 0, 0.28);
color: #fff7e6;
pointer-events: none;
}
.sh__ln {
display: block;
}
.t--c {
color: #beb7ad;
}
.t--s {
color: #ffd23f;
}
.t--n {
color: #6ec8ff;
}
.t--k {
color: #2b9d74;
}
.sh__ta {
background: transparent;
color: transparent;
caret-color: #ffd23f;
resize: none;
-webkit-text-fill-color: transparent;
}
.sh__ta::selection {
background: rgba(255, 210, 63, 0.32);
}
.sh__ta:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sh__note {
margin: 0;
color: #beb7ad;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.sh__sweep {
align-self: center;
justify-self: start;
width: 120px;
height: 72px;
border-radius: 8px;
background: linear-gradient(90deg, rgba(43, 157, 116, 0), rgba(43, 157, 116, 0.4), rgba(43, 157, 116, 0));
opacity: 0;
pointer-events: none;
}
.sh.is-demo .sh__ln {
animation-name: shLine;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sh.is-demo .sh__ln:nth-child(1) {
animation-delay: 0s;
}
.sh.is-demo .sh__ln:nth-child(2) {
animation-delay: 0.2s;
}
.sh.is-demo .sh__ln:nth-child(3) {
animation-delay: 0.4s;
}
.sh.is-demo .sh__ln:nth-child(4) {
animation-delay: 0.6s;
}
.sh.is-demo .sh__ln:nth-child(5) {
animation-delay: 0.8s;
}
.sh.is-demo .sh__ln:nth-child(6) {
animation-delay: 1s;
}
.sh.is-demo .sh__ln:nth-child(7) {
animation-name: shType;
animation-timing-function: steps(12, end);
animation-delay: 1.04s;
}
.sh.is-demo .sh__count {
animation-name: shPulse;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
transform-origin: 50% 50%;
}
.sh.is-demo .sh__sweep {
animation-name: shSweep;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes shSweep {
0%, 54% {
opacity: 0;
transform: translateX(-120px);
}
56% {
opacity: 1;
transform: translateX(-120px);
}
88% {
opacity: 1;
transform: translateX(400px);
}
90%, 100% {
opacity: 0;
transform: translateX(400px);
}
}
@keyframes shLine {
0% {
opacity: 0;
}
3%, 100% {
opacity: 1;
}
}
@keyframes shType {
0% {
clip-path: inset(0 100% 0 0);
}
48% {
clip-path: inset(0 0 0 0);
}
100% {
clip-path: inset(0 0 0 0);
}
}
@keyframes shPulse {
0%, 56% {
transform: scale(1);
}
64% {
transform: scale(1.22);
}
76%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.sh {
gap: 4px;
padding: 8px;
}
.sh__wrap {
height: 104px;
}
.sh__hl, .sh__ta {
font-size: 11px;
line-height: 16px;
padding: 4px;
}
.sh__note {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sh.is-demo .sh__ln,
.sh.is-demo .sh__sweep,
.sh.is-demo .sh__count {
animation: none !important;
opacity: 1 !important;
clip-path: none !important;
transform: none !important;
}
}var sh = document.getElementById('sh');
var ta = document.getElementById('ta');
var hl = document.getElementById('hl');
var wrap = document.getElementById('wrap');
// 토크나이저 규칙 네 줄. 앞의 규칙이 먼저 이긴다 — 주석 안의 색 코드를 색으로 잡지 않으려는 순서다
var RULES = [
{ cls: 'c', re: /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g },
{ cls: 's', re: /"[^"\n]*"|'[^'\n]*'/g },
{ cls: 'n', re: /#[0-9a-fA-F]{3,8}\b|\b\d+(?:\.\d+)?(?:px|ms|s|%|em|rem|fr)?\b/g },
{ cls: 'k', re: /--[a-zA-Z][\w-]*|\.[a-zA-Z][\w-]*|@[a-z-]+|\b[a-z-]+(?=\s*:)/g }
];
function esc(t) { return t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
function paint(line) {
var marks = [];
RULES.forEach(function (rule, rank) {
var m; rule.re.lastIndex = 0;
while ((m = rule.re.exec(line)) !== null) {
var taken = marks.some(function (x) { return m.index < x.end && x.start < m.index + m[0].length; });
if (!taken) marks.push({ start: m.index, end: m.index + m[0].length, cls: rule.cls, rank: rank });
if (m[0] === '') rule.re.lastIndex++;
}
});
marks.sort(function (a, b) { return a.start - b.start; });
var out = '', at = 0;
marks.forEach(function (x) {
if (x.start < at) return;
out += esc(line.slice(at, x.start)) + '<span class="t t--' + x.cls + '">' + esc(line.slice(x.start, x.end)) + '</span>';
at = x.end;
});
return out + esc(line.slice(at));
}
function render() {
// 줄마다 span 으로 감싼다 — 미리보기 루프가 줄 단위로 켜고 끌 수 있어야 한다
hl.innerHTML = ta.value.split('\n').map(function (line) {
return '<span class="sh__ln">' + (paint(line) || '') + '</span>';
}).join('');
}
function sync() { hl.scrollTop = ta.scrollTop; hl.scrollLeft = ta.scrollLeft; }
ta.addEventListener('input', function () { sh.classList.remove('is-demo'); render(); sync(); });
ta.addEventListener('scroll', sync);
ta.addEventListener('focus', function () { sh.classList.remove('is-demo'); });
wrap.addEventListener('pointerdown', function () { sh.classList.remove('is-demo'); });
render();// SyntaxHighlightEditor — vanilla 판(run/299/demo/01-editor-live-syntax-highlight/index.html) 의 React 포트.
// 글자를 투명하게 만든 textarea 를 같은 자리·같은 서체의 pre 위에 얹고, 입력이 들어오면 pre 만 다시 칠한다.
// 토크나이저 규칙 네 줄과 그 순서는 vanilla 와 같은 글자다 (값을 다시 타이핑하지 않는다).
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SyntaxHighlightEditor.module.scss";
export type SyntaxHighlightEditorProps = {
duration?: number;
easing?: string;
color?: string;
initialCode?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef<T> 는 T | null 을 돌려준다 — ambient 타입에 맞춰 상자 타입을 따로 둔다
type Box<T> = { current: T | null };
const LABEL = "코드 입력칸";
const NOTE = "입력하면 같은 자리에서 색이 다시 칠해집니다";
const COUNT = "4색";
const FILE_NAME = "style.scss";
const CODE = [
"/* card rise */",
".card {",
" --lift: 8px;",
' content: "rise";',
" color: #fff7e6;",
" transition: all 220ms ease;",
"}",
].join("\n");
type Rule = { cls: string; re: RegExp };
type Mark = { start: number; end: number; cls: string };
// 토크나이저 규칙 네 줄. 앞의 규칙이 먼저 이긴다 — 주석 안의 색 코드를 색으로 잡지 않으려는 순서다
const RULES: Rule[] = [
{ cls: "c", re: /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g },
{ cls: "s", re: /"[^"\n]*"|'[^'\n]*'/g },
{ cls: "n", re: /#[0-9a-fA-F]{3,8}\b|\b\d+(?:\.\d+)?(?:px|ms|s|%|em|rem|fr)?\b/g },
{ cls: "k", re: /--[a-zA-Z][\w-]*|\.[a-zA-Z][\w-]*|@[a-z-]+|\b[a-z-]+(?=\s*:)/g },
];
function escapeHtml(text: string): string {
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function paint(line: string): string {
const marks: Mark[] = [];
RULES.forEach((rule) => {
rule.re.lastIndex = 0;
let m = rule.re.exec(line);
while (m !== null) {
const start = m.index;
const end = m.index + m[0].length;
const taken = marks.some((x) => start < x.end && x.start < end);
if (!taken) marks.push({ start, end, cls: rule.cls });
if (m[0] === "") rule.re.lastIndex++;
m = rule.re.exec(line);
}
});
marks.sort((a, b) => a.start - b.start);
let out = "";
let at = 0;
marks.forEach((x) => {
if (x.start < at) return;
out += escapeHtml(line.slice(at, x.start)) + '<span class="' + styles.t + " " + styles["t--" + x.cls] + '">' +
escapeHtml(line.slice(x.start, x.end)) + "</span>";
at = x.end;
});
return out + escapeHtml(line.slice(at));
}
export default function SyntaxHighlightEditor(props: SyntaxHighlightEditorProps) {
const duration = props.duration ?? 220;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#2b9d74";
const [code, setCode] = useState<string>(props.initialCode ?? CODE);
const [preview, setPreview] = useState<boolean>(true);
const taRef = useRef<HTMLTextAreaElement>(null) as Box<HTMLTextAreaElement>;
const hlRef = useRef<HTMLPreElement>(null) as Box<HTMLPreElement>;
// 줄마다 span 으로 감싼다 — 미리보기 루프가 줄 단위로 켜고 끌 수 있어야 한다
const lines = useMemo(() => code.split("\n").map((line) => paint(line) || ""), [code]);
useEffect(() => {
const ta = taRef.current;
const hl = hlRef.current;
if (ta && hl) {
hl.scrollTop = ta.scrollTop;
hl.scrollLeft = ta.scrollLeft;
}
}, [code]);
const sync = () => {
const ta = taRef.current;
const hl = hlRef.current;
if (ta && hl) {
hl.scrollTop = ta.scrollTop;
hl.scrollLeft = ta.scrollLeft;
}
};
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div className={styles.root + (preview ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M216 48v160H40V48Z" opacity=".2" />
<path d="m69.12 94.15l-2.63-2.62a8 8 0 0 1 0-11.32l32-32a8 8 0 0 1 11.31 11.32L83.31 88l26.5 26.5a8 8 0 0 1-11.31 11.31Zm117.76 67.7l2.63 2.62a8 8 0 0 0 0-11.32l-32-32a8 8 0 0 0-11.31 11.32L172.69 168l-26.5 26.5a8 8 0 0 0 11.31 11.31Z" />
<path d="M168 42.15a8 8 0 0 0-9.85 5.53l-48 168a8 8 0 0 0 5.53 9.85a8.5 8.5 0 0 0 2.16.3a8 8 0 0 0 7.69-5.8l48-168a8 8 0 0 0-5.53-9.88" />
</g>
</svg>
</span>
<span className={styles.name}>{FILE_NAME}</span>
<span className={styles.count}>{COUNT}</span>
</div>
<div className={styles.wrap} onPointerDown={() => setPreview(false)}>
<pre
className={styles.hl}
ref={hlRef}
aria-hidden="true"
dangerouslySetInnerHTML={{
__html: lines.map((html) => '<span class="' + styles.ln + '">' + html + "</span>").join(""),
}}
/>
<span className={styles.sweep} aria-hidden="true" />
<textarea
className={styles.ta}
ref={taRef}
value={code}
spellCheck={false}
autoComplete="off"
aria-label={LABEL}
onChange={(e: { target: { value: string } }) => {
setPreview(false);
setCode(e.target.value);
}}
onScroll={sync}
onFocus={() => setPreview(false)}
/>
</div>
<p className={styles.note}>{NOTE}</p>
</div>
</main>
);
}01 · Live syntax highlight — Measured area 25.074% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Four regex rules, earliest wins
// Four tokenizer rules. The earlier rule wins — this order is what keeps a hex color inside a comment from being caught as a number
var RULES = [
{ cls: 'c', re: /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g },
{ cls: 's', re: /"[^"\n]*"|'[^'\n]*'/g },
{ cls: 'n', re: /#[0-9a-fA-F]{3,8}\b|\b\d+(?:\.\d+)?(?:px|ms|s|%|em|rem|fr)?\b/g },
{ cls: 'k', re: /--[a-zA-Z][\w-]*|\.[a-zA-Z][\w-]*|@[a-z-]+|\b[a-z-]+(?=\s*:)/g }
];
A range already claimed is off limits to every later rule, so a line that opens with /* and carries #ff4d1f comes back as a single comment span rather than a comment plus a color.
One block of shared metrics
%layer {
box-sizing: border-box; margin: 0; padding: $sp-2;
border: 0; border-radius: $r-sm;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
letter-spacing: 0; tab-size: 2;
white-space: pre; overflow: auto; overscroll-behavior: contain;
}
Family, size, line height, padding, letter spacing, and tab width live in one placeholder that both layers extend, which is why a computed-style diff of eight properties came back empty and a textarea scrolled to 120 put the pre at 120 as well.
02Line number gutter
The gutter grows and shrinks with the line count while only the caret's line lights up, which is what an editor needs the moment it has to point at a failing line.

<main class="stage">
<div class="gt is-demo" id="gt">
<div class="gt__bar">
<span class="gt__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 64v128H104V64Z" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8H104a8 8 0 0 1 0-16h112a8 8 0 0 1 8 8M104 72h112a8 8 0 0 0 0-16H104a8 8 0 0 0 0 16m112 112H104a8 8 0 0 0 0 16h112a8 8 0 0 0 0-16M43.58 55.16L48 52.94V104a8 8 0 0 0 16 0V40a8 8 0 0 0-11.58-7.16l-16 8a8 8 0 0 0 7.16 14.32m36.19 101.56a23.73 23.73 0 0 0-9.6-15.95a24.86 24.86 0 0 0-34.11 4.7a23.6 23.6 0 0 0-3.57 6.46a8 8 0 1 0 15 5.47a7.8 7.8 0 0 1 1.18-2.13a8.76 8.76 0 0 1 12-1.59a7.9 7.9 0 0 1 3.26 5.32a7.64 7.64 0 0 1-1.57 5.78a1 1 0 0 0-.08.11l-28.69 38.32A8 8 0 0 0 40 216h32a8 8 0 0 0 0-16H56l19.08-25.53a23.47 23.47 0 0 0 4.69-17.75"/></g></svg></span>
<span class="gt__name">editor.scss</span>
<span class="gt__pill">
<span class="gt__count" id="count">6 lines</span>
<span class="gt__cv gt__cv--1" aria-hidden="true">3 lines</span>
<span class="gt__cv gt__cv--2" aria-hidden="true">4 lines</span>
<span class="gt__cv gt__cv--3" aria-hidden="true">5 lines</span>
<span class="gt__cv gt__cv--4" aria-hidden="true">6 lines</span>
<span class="gt__cv gt__cv--5" aria-hidden="true">5 lines</span>
</span>
</div>
<div class="gt__body">
<div class="gt__gut" aria-hidden="true">
<div class="gt__gtrack" id="gtrack"></div>
</div>
<div class="gt__wrap" id="wrap">
<div class="gt__track" id="track">
<span class="gt__cur" id="cur" aria-hidden="true"></span>
<pre class="gt__hl" id="hl" aria-hidden="true"></pre>
</div>
<textarea class="gt__ta" id="ta" spellcheck="false" autocomplete="off" wrap="off" aria-label="Code input">/* gutter demo */
.panel {
margin: 8px;
line-height: 20px;
transform: translateY(4px);
}</textarea>
</div>
</div>
<p class="gt__note">Press Enter and the numbers grow; only the caret's line lights up</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 줄 번호 거터와 현재 줄 — 왼쪽 거터의 번호가 입력 줄 수에 맞춰 늘고 줄며,
// selectionStart 로 계산한 커서 줄만 배경이 켜진다. 본문(textarea)이 스크롤하면
// 거터 트랙도 같은 값으로 translateY 해 붙어 다닌다.
// 번호 자체는 CSS 카운터(counter-reset)가 매긴다 — JS 는 줄이 바뀔 때 빈 칸만 다시 그린다.
// 격자 미리보기(.is-demo)는 줄과 번호를 steps(1, end) 컷으로 하나씩 켜고,
// 현재 줄 밴드는 매끈하게 미끄러지며, 뒤에서 한 줄 스크롤 동기가 같이 움직인다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $stage-yellow;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$dim: #beb7ad;
$fs: clamp(11px, 3vw, 13px);
$lh: 20px;
@include stage($stage-ink);
.gt {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .05);
}
// ── 파일 이름 줄
.gt__bar { display: flex; align-items: center; gap: $sp-2; }
.gt__ico { display: grid; place-items: center; color: $color; font-size: 15px; }
.gt__name {
flex: 1 1 auto; color: $subject-cream;
font-family: $mono; font-weight: 700; font-size: 12px; line-height: 1.4;
}
// 줄 수 알약 — 실제 값은 JS 가 채우고, 미리보기에서는 겹켜진 값 장이 차례로 켜진다
.gt__pill { display: grid; justify-items: center; }
.gt__pill > * { grid-area: 1 / 1; }
.gt__count, .gt__cv {
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($color, .14); color: $color;
font-family: $mono; font-weight: 700; font-size: 11px; line-height: 1.4;
white-space: nowrap;
}
.gt__cv { opacity: 0; }
// ── 거터 + 본문 몸통
.gt__body {
display: grid; grid-template-columns: 36px 1fr;
background: rgba(0, 0, 0, .28); border-radius: $r-sm; overflow: hidden;
}
.gt__gut { background: rgba(0, 0, 0, .35); border-right: 1px solid $line; overflow: hidden; }
.gt__gtrack {
position: relative; box-sizing: border-box; padding: $sp-2 0 $sp-2 $sp-2;
counter-reset: gtline;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
}
.gt__num {
position: relative; z-index: 1; display: block; height: $lh;
padding-right: $sp-2; text-align: right; color: $dim;
}
.gt__num::before { counter-increment: gtline; content: counter(gtline); }
// 거터 쪽 현재 줄 밴드 — 본문 밴드와 같은 translateY 값을 받아 같이 움직인다
.gt__gcur {
position: absolute; top: $sp-2; left: 0; right: 0; height: $lh;
background: rgba($color, .18); border-radius: 0 $r-xs $r-xs 0;
transition: transform $duration $easing;
}
// ── 본문. 글자는 투명한 textarea 아래 pre 가 그린다 — 서체·행간·패딩이 글자 단위로 같아야 한다
.gt__wrap {
position: relative; display: grid; overflow: hidden;
height: 5 * $lh + 2 * $sp-2; // 5줄이 온전히 보이고 마지막 줄이 조금 엿보인다 — 스크롤 여유 표시
}
.gt__wrap > * { grid-area: 1 / 1; min-height: 0; }
// textarea 는 격자 칸 높이에 **묶어 둔다**. 내용만큼 늘어나게 두면 스크롤하는 것은 textarea 가 아니라
// 바깥 상자가 되고, scrollTop 을 읽어 거터를 미는 동기 코드가 영영 0 만 읽는다 (실측 2026-09-13:
// 12줄을 넣어도 ta.scrollHeight == ta.clientHeight 였다)
.gt__ta { height: 100%; }
.gt__track { position: relative; align-self: start; box-sizing: border-box; padding: $sp-2; }
.gt__cur {
position: absolute; top: $sp-2; left: 0; right: 0; height: $lh;
background: rgba($color, .18); border-radius: 0 $r-xs $r-xs 0;
transition: transform $duration $easing;
}
.gt__hl {
position: relative; z-index: 1; margin: 0;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
letter-spacing: 0; tab-size: 2; white-space: pre; overflow: hidden;
color: $subject-cream;
}
.gt__ln { display: block; }
// 글자는 투명하고 커서만 남는다 — 색은 아래 pre 가 그린다
.gt__ta {
box-sizing: border-box; margin: 0; padding: $sp-2; border: 0; background: transparent;
color: transparent; caret-color: $color; resize: none;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
letter-spacing: 0; tab-size: 2; white-space: pre; overflow: auto;
scrollbar-width: none;
}
.gt__ta::-webkit-scrollbar { display: none; }
.gt__ta::selection { background: rgba($color, .32); }
.gt__ta:focus-visible { outline: 2px solid $subject-blue; outline-offset: -2px; }
.gt__note { margin: 0; color: $dim; font-weight: 500; font-size: 11px; line-height: 1.4; }
// ── 격자 미리보기 루프 (2초)
// 1~3줄은 곧바로, 4·5줄은 타이핑하듯 늦게 켜지고 6줄이 더해졌다가 줄어든다.
// 켜짐·꺼짐은 전부 steps(1, end) 컷 — 점프 지점(3%)을 따로 둬야 첫 장에 값이 보인다.
.gt.is-demo .gt__ln, .gt.is-demo .gt__num {
animation-name: gtLine;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.gt.is-demo .gt__ln:nth-child(1), .gt.is-demo .gt__num:nth-child(1) { animation-delay: 0s; }
.gt.is-demo .gt__ln:nth-child(2), .gt.is-demo .gt__num:nth-child(2) { animation-delay: .04s; }
.gt.is-demo .gt__ln:nth-child(3), .gt.is-demo .gt__num:nth-child(3) { animation-delay: .08s; }
.gt.is-demo .gt__ln:nth-child(4), .gt.is-demo .gt__num:nth-child(4) { animation-delay: .3s; }
.gt.is-demo .gt__ln:nth-child(5), .gt.is-demo .gt__num:nth-child(5) { animation-delay: .42s; }
// 6줄과 그 번호는 한 바퀴 안에서 늘었다가 줄어든다 — 지연 없이 절대 시점 키프레임으로
.gt.is-demo .gt__ln:nth-child(6), .gt.is-demo .gt__num:nth-child(6) { animation-name: gtLineCut; }
// 줄 수 알약 — 실제 값은 감추고 겹켜진 값 장을 컷으로 갈아 끼운다
.gt.is-demo .gt__count { color: transparent; }
.gt.is-demo .gt__cv {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.gt.is-demo .gt__cv--1 { animation-name: gtCv1; }
.gt.is-demo .gt__cv--2 { animation-name: gtCv2; }
.gt.is-demo .gt__cv--3 { animation-name: gtCv3; }
.gt.is-demo .gt__cv--4 { animation-name: gtCv4; }
.gt.is-demo .gt__cv--5 { animation-name: gtCv5; }
// 현재 줄 밴드 — 나타남은 컷, 줄 옮김은 매끈한 transform 이동
.gt.is-demo .gt__cur, .gt.is-demo .gt__gcur {
animation-name: gtCurO, gtCurT;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
transition: none;
}
// 스크롤 동기 — 본문 트랙과 거터 트랙이 같은 값으로 한 줄 올라갔다 내려온다
.gt.is-demo .gt__track, .gt.is-demo .gt__gtrack {
animation-name: gtScroll;
animation-duration: $dur-loop; animation-timing-function: $ease-out;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes gtLine {
0% { opacity: 0; }
3%, 100% { opacity: 1; }
}
@keyframes gtLineCut {
0%, 29% { opacity: 0; }
30%, 79% { opacity: 1; }
80%, 100% { opacity: 0; }
}
@keyframes gtCv1 { 0%, 17% { opacity: 1; } 18%, 100% { opacity: 0; } }
@keyframes gtCv2 { 0%, 17% { opacity: 0; } 18%, 23% { opacity: 1; } 24%, 100% { opacity: 0; } }
@keyframes gtCv3 { 0%, 23% { opacity: 0; } 24%, 29% { opacity: 1; } 30%, 100% { opacity: 0; } }
@keyframes gtCv4 { 0%, 29% { opacity: 0; } 30%, 79% { opacity: 1; } 80%, 100% { opacity: 0; } }
@keyframes gtCv5 { 0%, 79% { opacity: 0; } 80%, 100% { opacity: 1; } }
@keyframes gtCurO {
0% { opacity: 0; }
12%, 100% { opacity: 1; }
}
// 셋째 줄에서 타이핑을 따라 넷째·다섯째·여섯째 줄로 내려간 뒤, 클릭해 셋째 줄로 돌아온다
@keyframes gtCurT {
0%, 12% { transform: translateY(40px); }
21%, 24% { transform: translateY(60px); }
27%, 30% { transform: translateY(80px); }
33%, 44% { transform: translateY(100px); }
50%, 100% { transform: translateY(40px); }
}
@keyframes gtScroll {
0%, 65% { transform: translateY(0); }
70%, 74% { transform: translateY(-20px); }
79%, 100% { transform: translateY(0); }
}
// ── 좁은 화면: 여백과 보이는 줄 수를 줄여 무대 안에 들어온다 (줄 높이 20px 는 그대로)
@media (max-width: 340px) {
.gt { gap: $sp-1; padding: $sp-2; }
.gt__note { display: none; } // 좁은 화면에서는 안내문을 접어 무대(200px) 안에 들어오게 한다
.gt__body { grid-template-columns: 28px 1fr; }
.gt__wrap { height: 3 * $lh + 2 * $sp-1; }
.gt__gtrack { padding: $sp-1 0 $sp-1 $sp-1; font-size: 11px; }
.gt__num { padding-right: $sp-1; }
.gt__track { padding: $sp-1; }
.gt__cur, .gt__gcur { top: $sp-1; }
.gt__hl { font-size: 11px; }
.gt__ta { padding: $sp-1; font-size: 11px; }
.gt__note { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.gt.is-demo .gt__ln, .gt.is-demo .gt__num, .gt.is-demo .gt__cv,
.gt.is-demo .gt__cur, .gt.is-demo .gt__gcur, .gt.is-demo .gt__track, .gt.is-demo .gt__gtrack {
animation: none !important;
}
.gt.is-demo .gt__ln, .gt.is-demo .gt__num { opacity: 1 !important; }
.gt.is-demo .gt__cv { opacity: 0 !important; }
.gt.is-demo .gt__count { color: $color !important; }
.gt.is-demo .gt__cur, .gt.is-demo .gt__gcur { opacity: 1 !important; }
}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);
}
.gt {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
}
.gt__bar {
display: flex;
align-items: center;
gap: 8px;
}
.gt__ico {
display: grid;
place-items: center;
color: #ffd23f;
font-size: 15px;
}
.gt__name {
flex: 1 1 auto;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
}
.gt__pill {
display: grid;
justify-items: center;
}
.gt__pill > * {
grid-area: 1/1;
}
.gt__count, .gt__cv {
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 210, 63, 0.14);
color: #ffd23f;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
}
.gt__cv {
opacity: 0;
}
.gt__body {
display: grid;
grid-template-columns: 36px 1fr;
background: rgba(0, 0, 0, 0.28);
border-radius: 8px;
overflow: hidden;
}
.gt__gut {
background: rgba(0, 0, 0, 0.35);
border-right: 1px solid rgba(255, 255, 255, 0.16);
overflow: hidden;
}
.gt__gtrack {
position: relative;
box-sizing: border-box;
padding: 8px 0 8px 8px;
counter-reset: gtline;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
}
.gt__num {
position: relative;
z-index: 1;
display: block;
height: 20px;
padding-right: 8px;
text-align: right;
color: #beb7ad;
}
.gt__num::before {
counter-increment: gtline;
content: counter(gtline);
}
.gt__gcur {
position: absolute;
top: 8px;
left: 0;
right: 0;
height: 20px;
background: rgba(255, 210, 63, 0.18);
border-radius: 0 4px 4px 0;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gt__wrap {
position: relative;
display: grid;
overflow: hidden;
height: 116px;
}
.gt__wrap > * {
grid-area: 1/1;
min-height: 0;
}
.gt__ta {
height: 100%;
}
.gt__track {
position: relative;
align-self: start;
box-sizing: border-box;
padding: 8px;
}
.gt__cur {
position: absolute;
top: 8px;
left: 0;
right: 0;
height: 20px;
background: rgba(255, 210, 63, 0.18);
border-radius: 0 4px 4px 0;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gt__hl {
position: relative;
z-index: 1;
margin: 0;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
letter-spacing: 0;
tab-size: 2;
white-space: pre;
overflow: hidden;
color: #fff7e6;
}
.gt__ln {
display: block;
}
.gt__ta {
box-sizing: border-box;
margin: 0;
padding: 8px;
border: 0;
background: transparent;
color: transparent;
caret-color: #ffd23f;
resize: none;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
letter-spacing: 0;
tab-size: 2;
white-space: pre;
overflow: auto;
scrollbar-width: none;
}
.gt__ta::-webkit-scrollbar {
display: none;
}
.gt__ta::selection {
background: rgba(255, 210, 63, 0.32);
}
.gt__ta:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.gt__note {
margin: 0;
color: #beb7ad;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.gt.is-demo .gt__ln, .gt.is-demo .gt__num {
animation-name: gtLine;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.gt.is-demo .gt__ln:nth-child(1), .gt.is-demo .gt__num:nth-child(1) {
animation-delay: 0s;
}
.gt.is-demo .gt__ln:nth-child(2), .gt.is-demo .gt__num:nth-child(2) {
animation-delay: 0.04s;
}
.gt.is-demo .gt__ln:nth-child(3), .gt.is-demo .gt__num:nth-child(3) {
animation-delay: 0.08s;
}
.gt.is-demo .gt__ln:nth-child(4), .gt.is-demo .gt__num:nth-child(4) {
animation-delay: 0.3s;
}
.gt.is-demo .gt__ln:nth-child(5), .gt.is-demo .gt__num:nth-child(5) {
animation-delay: 0.42s;
}
.gt.is-demo .gt__ln:nth-child(6), .gt.is-demo .gt__num:nth-child(6) {
animation-name: gtLineCut;
}
.gt.is-demo .gt__count {
color: transparent;
}
.gt.is-demo .gt__cv {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.gt.is-demo .gt__cv--1 {
animation-name: gtCv1;
}
.gt.is-demo .gt__cv--2 {
animation-name: gtCv2;
}
.gt.is-demo .gt__cv--3 {
animation-name: gtCv3;
}
.gt.is-demo .gt__cv--4 {
animation-name: gtCv4;
}
.gt.is-demo .gt__cv--5 {
animation-name: gtCv5;
}
.gt.is-demo .gt__cur, .gt.is-demo .gt__gcur {
animation-name: gtCurO, gtCurT;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
transition: none;
}
.gt.is-demo .gt__track, .gt.is-demo .gt__gtrack {
animation-name: gtScroll;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes gtLine {
0% {
opacity: 0;
}
3%, 100% {
opacity: 1;
}
}
@keyframes gtLineCut {
0%, 29% {
opacity: 0;
}
30%, 79% {
opacity: 1;
}
80%, 100% {
opacity: 0;
}
}
@keyframes gtCv1 {
0%, 17% {
opacity: 1;
}
18%, 100% {
opacity: 0;
}
}
@keyframes gtCv2 {
0%, 17% {
opacity: 0;
}
18%, 23% {
opacity: 1;
}
24%, 100% {
opacity: 0;
}
}
@keyframes gtCv3 {
0%, 23% {
opacity: 0;
}
24%, 29% {
opacity: 1;
}
30%, 100% {
opacity: 0;
}
}
@keyframes gtCv4 {
0%, 29% {
opacity: 0;
}
30%, 79% {
opacity: 1;
}
80%, 100% {
opacity: 0;
}
}
@keyframes gtCv5 {
0%, 79% {
opacity: 0;
}
80%, 100% {
opacity: 1;
}
}
@keyframes gtCurO {
0% {
opacity: 0;
}
12%, 100% {
opacity: 1;
}
}
@keyframes gtCurT {
0%, 12% {
transform: translateY(40px);
}
21%, 24% {
transform: translateY(60px);
}
27%, 30% {
transform: translateY(80px);
}
33%, 44% {
transform: translateY(100px);
}
50%, 100% {
transform: translateY(40px);
}
}
@keyframes gtScroll {
0%, 65% {
transform: translateY(0);
}
70%, 74% {
transform: translateY(-20px);
}
79%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.gt {
gap: 4px;
padding: 8px;
}
.gt__note {
display: none;
}
.gt__body {
grid-template-columns: 28px 1fr;
}
.gt__wrap {
height: 68px;
}
.gt__gtrack {
padding: 4px 0 4px 4px;
font-size: 11px;
}
.gt__num {
padding-right: 4px;
}
.gt__track {
padding: 4px;
}
.gt__cur, .gt__gcur {
top: 4px;
}
.gt__hl {
font-size: 11px;
}
.gt__ta {
padding: 4px;
font-size: 11px;
}
.gt__note {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.gt.is-demo .gt__ln, .gt.is-demo .gt__num, .gt.is-demo .gt__cv,
.gt.is-demo .gt__cur, .gt.is-demo .gt__gcur, .gt.is-demo .gt__track, .gt.is-demo .gt__gtrack {
animation: none !important;
}
.gt.is-demo .gt__ln, .gt.is-demo .gt__num {
opacity: 1 !important;
}
.gt.is-demo .gt__cv {
opacity: 0 !important;
}
.gt.is-demo .gt__count {
color: #ffd23f !important;
}
.gt.is-demo .gt__cur, .gt.is-demo .gt__gcur {
opacity: 1 !important;
}
}var gt = document.getElementById('gt');
var ta = document.getElementById('ta');
var hl = document.getElementById('hl');
var wrap = document.getElementById('wrap');
var track = document.getElementById('track');
var gtrack = document.getElementById('gtrack');
var countEl = document.getElementById('count');
var curEl = document.getElementById('cur');
var gcurEl = null;
var LBL_COUNT = '{n} lines';
function esc(t) {
return t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
function lineH() {
var v = parseFloat(getComputedStyle(ta).lineHeight);
return v > 0 ? v : 20;
}
// 줄 수가 바뀔 때마다 거터를 다시 그린다 — 번호 매김은 scss 의 counter-reset 카운터가 한다
function render() {
var lines = ta.value.split('\n');
var nums = '';
lines.forEach(function () { nums += '<span class="gt__num"></span>'; });
gtrack.innerHTML = nums + '<span class="gt__gcur"></span>';
gcurEl = gtrack.querySelector('.gt__gcur');
hl.innerHTML = lines.map(function (l) {
return '<span class="gt__ln">' + (esc(l) || ' ') + '</span>';
}).join('');
countEl.textContent = LBL_COUNT.replace('{n}', lines.length);
update();
}
// selectionStart 까지의 글자에서 몇째 줄인지 계산해 두 밴드를 같은 값으로 옮긴다
function update() {
var idx = ta.value.slice(0, ta.selectionStart).split('\n').length - 1;
var y = idx * lineH();
curEl.style.transform = 'translateY(' + y + 'px)';
if (gcurEl) gcurEl.style.transform = 'translateY(' + y + 'px)';
sync();
}
// 본문이 스크롤하면 거터 트랙도 같은 값으로 translateY — 두 겹이 어긋나지 않는 핵심
function sync() {
var y = -ta.scrollTop;
track.style.transform = 'translateY(' + y + 'px)';
gtrack.style.transform = 'translateY(' + y + 'px)';
}
ta.addEventListener('input', function () { gt.classList.remove('is-demo'); render(); });
ta.addEventListener('keyup', function () { gt.classList.remove('is-demo'); update(); });
ta.addEventListener('click', function () { gt.classList.remove('is-demo'); update(); });
ta.addEventListener('scroll', sync);
ta.addEventListener('focus', function () { gt.classList.remove('is-demo'); update(); });
wrap.addEventListener('pointerdown', function () { gt.classList.remove('is-demo'); });
render();
// 시작 커서는 셋째 줄 끝 — 손을 대지 않은 첫 화면부터 현재 줄 밴드가 보인다
var p = ta.value.split('\n').slice(0, 3).join('\n').length;
ta.setSelectionRange(p, p);
update();// LineNumberGutter — vanilla 판(run/299/demo/02-editor-line-number-gutter/index.html) 의 React 포트.
// 왼쪽 거터의 번호가 입력 줄 수를 따라 늘고 줄며, selectionStart 로 잰 커서 줄만 배경이 켜진다.
// 본문이 스크롤하면 거터 트랙도 같은 translateY 값을 받아 붙어 다닌다.
// 번호 자체는 scss 의 counter-reset 이 매긴다 — 여기서는 빈 칸만 줄 수만큼 그린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LineNumberGutter.module.scss";
export type LineNumberGutterProps = {
duration?: number;
easing?: string;
color?: string;
initialCode?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef<T> 는 T | null 을 돌려준다 — ambient 타입에 맞춰 상자 타입을 따로 둔다
type Box<T> = { current: T | null };
const LABEL = "코드 입력칸";
const NOTE = "Enter 를 치면 번호가 늘고, 커서가 있는 줄만 밝아집니다";
const FILE_NAME = "editor.scss";
const LBL_COUNT = "{n}줄";
// 미리보기에서 겹켜놓고 컷으로 갈아 끼우는 줄 수 값 장 — vanilla 와 같은 글자다
const COUNT_VIEWS = ["3줄", "4줄", "5줄", "6줄", "5줄"];
const CODE = [
"/* gutter demo */",
".panel {",
" margin: 8px;",
" line-height: 20px;",
" transform: translateY(4px);",
"}",
].join("\n");
export default function LineNumberGutter(props: LineNumberGutterProps) {
const duration = props.duration ?? 220;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#ffd23f";
const [code, setCode] = useState<string>(props.initialCode ?? CODE);
const [preview, setPreview] = useState<boolean>(true);
const [caretY, setCaretY] = useState<number>(0);
const [scrollY, setScrollY] = useState<number>(0);
const taRef = useRef<HTMLTextAreaElement>(null) as Box<HTMLTextAreaElement>;
const lines = code.split("\n");
// 본문이 스크롤하면 두 트랙을 같은 값으로 옮긴다 — 두 겹이 어긋나지 않는 핵심
const sync = () => {
const ta = taRef.current;
if (ta) setScrollY(-ta.scrollTop);
};
// selectionStart 까지의 글자에서 몇째 줄인지 계산해 두 밴드를 같은 값으로 옮긴다
const update = () => {
const ta = taRef.current;
if (!ta) return;
const raw = parseFloat(getComputedStyle(ta).lineHeight);
const lh = raw > 0 ? raw : 20;
const idx = ta.value.slice(0, ta.selectionStart).split("\n").length - 1;
setCaretY(idx * lh);
setScrollY(-ta.scrollTop);
};
// 시작 커서는 셋째 줄 끝 — 손을 대지 않은 첫 화면부터 현재 줄 밴드가 보인다
useEffect(() => {
const ta = taRef.current;
if (!ta) return;
const p = ta.value.split("\n").slice(0, 3).join("\n").length;
ta.setSelectionRange(p, p);
update();
}, []);
const leave = () => setPreview(false);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const bandStyle: CSSProperties = { transform: "translateY(" + caretY + "px)" };
const trackStyle: CSSProperties = { transform: "translateY(" + scrollY + "px)" };
return (
<main className={styles.stage}>
<div className={styles.root + (preview ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M216 64v128H104V64Z" opacity=".2" />
<path d="M224 128a8 8 0 0 1-8 8H104a8 8 0 0 1 0-16h112a8 8 0 0 1 8 8M104 72h112a8 8 0 0 0 0-16H104a8 8 0 0 0 0 16m112 112H104a8 8 0 0 0 0 16h112a8 8 0 0 0 0-16M43.58 55.16L48 52.94V104a8 8 0 0 0 16 0V40a8 8 0 0 0-11.58-7.16l-16 8a8 8 0 0 0 7.16 14.32m36.19 101.56a23.73 23.73 0 0 0-9.6-15.95a24.86 24.86 0 0 0-34.11 4.7a23.6 23.6 0 0 0-3.57 6.46a8 8 0 1 0 15 5.47a7.8 7.8 0 0 1 1.18-2.13a8.76 8.76 0 0 1 12-1.59a7.9 7.9 0 0 1 3.26 5.32a7.64 7.64 0 0 1-1.57 5.78a1 1 0 0 0-.08.11l-28.69 38.32A8 8 0 0 0 40 216h32a8 8 0 0 0 0-16H56l19.08-25.53a23.47 23.47 0 0 0 4.69-17.75" />
</g>
</svg>
</span>
<span className={styles.name}>{FILE_NAME}</span>
<span className={styles.pill}>
<span className={styles.count}>{LBL_COUNT.replace("{n}", String(lines.length))}</span>
{COUNT_VIEWS.map((text, i) => (
<span key={i} className={styles.cv + " " + styles["cv" + (i + 1)]} aria-hidden="true">
{text}
</span>
))}
</span>
</div>
<div className={styles.body}>
<div className={styles.gut} aria-hidden="true">
<div className={styles.gtrack} style={trackStyle}>
{lines.map((_line, i) => (
<span key={i} className={styles.num} />
))}
<span className={styles.gcur} style={bandStyle} />
</div>
</div>
<div className={styles.wrap} onPointerDown={leave}>
<div className={styles.track} style={trackStyle}>
<span className={styles.cur} style={bandStyle} aria-hidden="true" />
<pre className={styles.hl} aria-hidden="true">
{lines.map((line, i) => (
<span key={i} className={styles.ln}>
{line || " "}
</span>
))}
</pre>
</div>
<textarea
className={styles.ta}
ref={taRef}
value={code}
spellCheck={false}
autoComplete="off"
wrap="off"
aria-label={LABEL}
onChange={(e: { target: { value: string } }) => {
leave();
setCode(e.target.value);
update();
}}
onKeyUp={() => {
leave();
update();
}}
onClick={() => {
leave();
update();
}}
onFocus={() => {
leave();
update();
}}
onScroll={sync}
/>
</div>
</div>
<p className={styles.note}>{NOTE}</p>
</div>
</main>
);
}02 · Line number gutter — Measured area 24.353% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Numbers that the stylesheet counts
// Redraw the gutter whenever the line count changes — the numbering itself is done by the counter-reset counter in the scss
function render() {
var lines = ta.value.split('\n');
var nums = '';
lines.forEach(function () { nums += '<span class="gt__num"></span>'; });
gtrack.innerHTML = nums + '<span class="gt__gcur"></span>';
gcurEl = gtrack.querySelector('.gt__gcur');
hl.innerHTML = lines.map(function (l) {
return '<span class="gt__ln">' + (esc(l) || ' ') + '</span>';
}).join('');
countEl.textContent = LBL_COUNT.replace('{n}', lines.length);
update();
}
JavaScript stamps one empty span per line and never writes a digit, and typing two more lines into the six-line sample moved the gutter from 6 numbers to 8.
Caret line from selectionStart
// Work out which line the caret is on from the text before selectionStart, then move both bands by the same value
function update() {
var idx = ta.value.slice(0, ta.selectionStart).split('\n').length - 1;
var y = idx * lineH();
curEl.style.transform = 'translateY(' + y + 'px)';
if (gcurEl) gcurEl.style.transform = 'translateY(' + y + 'px)';
sync();
}
Both bands take the same translateY, so scrolling the text to 40 leaves the gutter track at matrix(1, 0, 0, 1, 0, -40) and the two columns stay locked.
03Auto-close brackets
Typing an opening bracket inserts its partner and leaves the caret between the two, which is the difference between a snippet box you type in and one you fight.

<main class="stage">
<div class="br is-demo" id="br">
<div class="br__bar">
<span class="br__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M240 128c-64 0 0 88-64 88H80c-64 0 0-88-64-88c64 0 0-88 64-88h96c64 0 0 88 64 88" opacity=".2"/><path d="M43.18 128a29.8 29.8 0 0 1 8 10.26c4.8 9.9 4.8 22 4.8 33.74c0 24.31 1 36 24 36a8 8 0 0 1 0 16c-17.48 0-29.32-6.14-35.2-18.26c-4.8-9.9-4.8-22-4.8-33.74c0-24.31-1-36-24-36a8 8 0 0 1 0-16c23 0 24-11.69 24-36c0-11.72 0-23.84 4.8-33.74C50.68 38.14 62.52 32 80 32a8 8 0 0 1 0 16c-23 0-24 11.69-24 36c0 11.72 0 23.84-4.8 33.74A29.8 29.8 0 0 1 43.18 128M240 120c-23 0-24-11.69-24-36c0-11.72 0-23.84-4.8-33.74C205.32 38.14 193.48 32 176 32a8 8 0 0 0 0 16c23 0 24 11.69 24 36c0 11.72 0 23.84 4.8 33.74a29.8 29.8 0 0 0 8 10.26a29.8 29.8 0 0 0-8 10.26c-4.8 9.9-4.8 22-4.8 33.74c0 24.31-1 36-24 36a8 8 0 0 0 0 16c17.48 0 29.32-6.14 35.2-18.26c4.8-9.9 4.8-22 4.8-33.74c0-24.31 1-36 24-36a8 8 0 0 0 0-16"/></g></svg></span>
<span class="br__name">auto-close.js</span>
<span class="br__pill">
<span class="br__state" id="state">Auto-close</span>
<span class="br__sv br__sv--1" aria-hidden="true">Pair added</span>
<span class="br__sv br__sv--2" aria-hidden="true">Step over</span>
<span class="br__sv br__sv--3" aria-hidden="true">Pair removed</span>
</span>
</div>
<div class="br__wrap" id="wrap">
<pre class="br__hl" id="hl" aria-hidden="true"></pre>
<span class="br__pline" aria-hidden="true"></span>
<span class="br__ping" aria-hidden="true"></span>
<span class="br__caret" aria-hidden="true"></span>
<textarea class="br__ta" id="ta" spellcheck="false" autocomplete="off" wrap="off" aria-label="Code input">fade()</textarea>
</div>
<div class="br__keys" aria-hidden="true">
<span class="br__k"><span class="br__kt">(</span><span class="br__kfill">(</span></span>
<span class="br__k"><span class="br__kt">)</span><span class="br__kfill">)</span></span>
<span class="br__k"><span class="br__kt">Backspace</span><span class="br__kfill">Backspace</span></span>
<span class="br__k"><span class="br__kt">(</span><span class="br__kfill">(</span></span>
</div>
<p class="br__note">The closing partner is inserted for you; type it again at that spot and the caret just steps over</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 괄호·따옴표 자동 닫기 — 여는 짝을 치면 닫는 짝이 같이 들어가고 setSelectionRange 로
// 커서는 그 사이에 남는다. 이미 닫혀 있는 자리에서 닫는 글자를 치면 preventDefault 로
// 삽입을 막고 커서만 넘어가며, 빈 짝 가운데서 Backspace 를 치면 두 글자가 함께 지워진다.
// 격자 미리보기(.is-demo)는 fade 를 타이핑한 뒤 ( 를 쳐서 짝이 튀어들어오는 컷을 보여준다.
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue-deep;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$edge: rgba(23, 20, 26, .14);
$muted: rgba(23, 20, 26, .72);
$fs: 15px;
$lh: 20px;
@include stage($stage-paper);
.br {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $edge; border-radius: $r-card;
background: $subject-cream; box-shadow: $shadow-raised;
}
// ── 파일 이름 줄
.br__bar { display: flex; align-items: center; gap: $sp-2; }
.br__ico { display: grid; place-items: center; color: $subject-blue; font-size: 15px; }
.br__name {
flex: 1 1 auto; color: $subject-ink;
font-family: $mono; font-weight: 700; font-size: 12px; line-height: 1.4;
}
// 상태 알약 — 실제 값은 JS 가 채우고, 미리보기에서는 겹켜진 값 장이 차례로 켜진다
.br__pill { display: grid; justify-items: center; }
.br__pill > * { grid-area: 1 / 1; }
.br__state, .br__sv {
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: $color; color: $stage-paper;
font-family: $mono; font-weight: 700; font-size: 11px; line-height: 1.4;
white-space: nowrap;
}
.br__sv { opacity: 0; }
// ── 코드 줄. 글자는 투명한 textarea 아래 pre 가 그린다
.br__wrap {
position: relative; display: grid; overflow: hidden;
box-sizing: border-box; height: 2 * $lh + 2 * $sp-2;
background: $stage-paper; border: 1px solid $edge; border-radius: $r-sm;
}
.br__wrap > * { grid-area: 1 / 1; }
%brlayer {
box-sizing: border-box; margin: 0; padding: $sp-2; border: 0;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
letter-spacing: 0; tab-size: 2; white-space: pre;
}
.br__hl {
@extend %brlayer;
overflow: hidden; background: transparent; color: $subject-ink;
}
.br__ln { display: block; }
.br__pp { color: $color; font-weight: 700; }
// 글자는 투명하고 커서만 남는다 — 짝 색은 아래 pre 가 그린다
.br__ta {
@extend %brlayer;
background: transparent; color: transparent; caret-color: $subject-ink;
resize: none; overflow: auto; scrollbar-width: none;
}
.br__ta::-webkit-scrollbar { display: none; }
.br__ta::selection { background: rgba($color, .2); }
.br__ta:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
// ── 미리보기 전용 — 가짜 커서·짝 연결선·삽입 핑. 정적 경로(사람 조작)에서는 전부 숨는다
// ch 단위로 자리를 잡는다 — 고정폭 글꼴에서 1ch 가 글자 한 칸 폭이라 계산이 필요 없다
.br__caret, .br__pline, .br__ping {
font-family: $mono; font-size: $fs; line-height: $lh;
pointer-events: none; opacity: 0;
}
.br__caret {
position: absolute; top: $sp-2; left: $sp-2; width: 2px; height: $lh;
background: $subject-ink; z-index: 2;
}
.br__pline {
position: absolute; bottom: $sp-2; left: calc(#{$sp-2} + 4ch); width: 2ch; height: 2px;
background: $color; transform-origin: left center; z-index: 1;
}
.br__ping {
position: absolute; top: $sp-2; left: calc(#{$sp-2} + 4ch); width: 2ch; height: $lh;
border: 2px solid $color; border-radius: $r-xs; z-index: 1;
}
// ── 키 기록 줄 — 친 키가 오른쪽으로 밀려 마지막 칸이 파랗게 켜진다
.br__keys { display: grid; grid-template-columns: repeat(4, 1fr); gap: $sp-2; }
.br__k {
position: relative; display: grid; place-items: center; overflow: hidden;
height: 26px; border: 1px solid $edge; border-radius: $r-xs;
background: rgba(23, 20, 26, .05);
font-family: $mono; font-weight: 700; font-size: 11px; line-height: 1.4;
white-space: nowrap;
}
.br__kt { color: $muted; }
.br__kfill {
position: absolute; inset: 0; display: grid; place-items: center;
background: $color; color: $stage-paper; opacity: 0;
transition: opacity $duration $easing;
}
.br__k.is-on .br__kfill { opacity: 1; }
.br__note { margin: 0; color: $muted; font-weight: 500; font-size: 11px; line-height: 1.4; }
// ── 격자 미리보기 루프 (2초)
// fade 넉 자를 타이핑하고 10% 에 ( 를 치면 짝이 함께 튀어들어온다. 35% 는 닫는 짝 넘어가기,
// 60% 는 Backspace 짝 삭제, 72% 에 다시 삽입 — 상태 교체는 전부 steps(1, end) 컷이다.
.br.is-demo .br__txt {
animation-name: brType;
animation-duration: $dur-loop; animation-timing-function: steps(4, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.br.is-demo .br__pp {
animation-name: brPp;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
// 가짜 커서 — 0.5초 깜빡임과 2초 이동 두 애니메이션을 나란히 돌린다
.br.is-demo .br__caret {
animation-name: brCaretO, brCaretX;
animation-duration: .5s, $dur-loop;
animation-timing-function: steps(1, end), $ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.br.is-demo .br__pline {
animation-name: brUlO, brUlX;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.br.is-demo .br__ping {
animation-name: brPing;
animation-duration: $dur-loop; animation-timing-function: $ease-out;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
// 상태 알약 — 실제 값은 감추고 겹켜진 값 장을 컷으로 갈아 끼운다
.br.is-demo .br__state { color: transparent; }
.br.is-demo .br__sv {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.br.is-demo .br__sv--1 { animation-name: brSv1; }
.br.is-demo .br__sv--2 { animation-name: brSv2; }
.br.is-demo .br__sv--3 { animation-name: brSv3; }
// 키 칸 — 켜짐은 컷, 눌림은 2px transform 으로 표시한다
.br.is-demo .br__kfill, .br.is-demo .br__k {
animation-duration: $dur-loop;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.br.is-demo .br__kfill { animation-timing-function: steps(1, end); }
.br.is-demo .br__k { animation-timing-function: $ease-out; transition: none; }
.br.is-demo .br__k:nth-child(1) .br__kfill { animation-name: brKf1; }
.br.is-demo .br__k:nth-child(2) .br__kfill { animation-name: brKf2; }
.br.is-demo .br__k:nth-child(3) .br__kfill { animation-name: brKf3; }
.br.is-demo .br__k:nth-child(4) .br__kfill { animation-name: brKf4; }
.br.is-demo .br__k:nth-child(1) { animation-name: brKp1; }
.br.is-demo .br__k:nth-child(2) { animation-name: brKp2; }
.br.is-demo .br__k:nth-child(3) { animation-name: brKp3; }
.br.is-demo .br__k:nth-child(4) { animation-name: brKp4; }
@keyframes brType {
0%, 2% { clip-path: inset(0 100% 0 0); }
14%, 100% { clip-path: inset(0 0 0 0); }
}
// 짝은 살짝 크게 나타났다가 3% 뒤에 제 자리 크기로 앉는다 — 전 구간 steps 컷이라
// 샘플 사이 어중간한 크기의 글자가 프레임에 잡히지 않는다
@keyframes brPp {
0%, 9% { opacity: 0; transform: scale(1.35); }
10%, 12% { opacity: 1; transform: scale(1.35); }
13%, 59% { opacity: 1; transform: scale(1); }
60%, 71% { opacity: 0; transform: scale(1); }
72%, 74% { opacity: 1; transform: scale(1.35); }
75%, 100% { opacity: 1; transform: scale(1); }
}
@keyframes brCaretO {
0% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 1; }
}
// 4ch 는 fade 끝, 5ch 는 짝 가운데, 6ch 는 닫는 짝 뒤 — ch 로 재니 글꼴 폭과 무관하다
@keyframes brCaretX {
0%, 9% { transform: translateX(4ch); }
10%, 34% { transform: translateX(5ch); }
40%, 51% { transform: translateX(6ch); }
56%, 59% { transform: translateX(5ch); }
60%, 71% { transform: translateX(4ch); }
72%, 100% { transform: translateX(5ch); }
}
@keyframes brUlO {
0%, 9% { opacity: 0; }
10%, 59% { opacity: 1; }
60%, 71% { opacity: 0; }
72%, 100% { opacity: 1; }
}
@keyframes brUlX {
0%, 10% { transform: scaleX(0); }
16%, 59% { transform: scaleX(1); }
60%, 72% { transform: scaleX(0); }
78%, 100% { transform: scaleX(1); }
}
@keyframes brPing {
0%, 9% { opacity: 0; transform: scale(.8); }
10% { opacity: .9; transform: scale(.8); }
17%, 71% { opacity: 0; transform: scale(1.5); }
72% { opacity: .9; transform: scale(.8); }
79%, 100% { opacity: 0; transform: scale(1.5); }
}
@keyframes brSv1 {
0%, 9% { opacity: 0; }
10%, 34% { opacity: 1; }
35%, 71% { opacity: 0; }
72%, 100% { opacity: 1; }
}
@keyframes brSv2 {
0%, 34% { opacity: 0; }
35%, 51% { opacity: 1; }
52%, 100% { opacity: 0; }
}
@keyframes brSv3 {
0%, 51% { opacity: 0; }
52%, 71% { opacity: 1; }
72%, 100% { opacity: 0; }
}
@keyframes brKf1 {
0%, 9% { opacity: 0; }
10%, 34% { opacity: 1; }
35%, 100% { opacity: 0; }
}
@keyframes brKf2 {
0%, 34% { opacity: 0; }
35%, 51% { opacity: 1; }
52%, 100% { opacity: 0; }
}
@keyframes brKf3 {
0%, 51% { opacity: 0; }
52%, 71% { opacity: 1; }
72%, 100% { opacity: 0; }
}
@keyframes brKf4 {
0%, 71% { opacity: 0; }
72%, 100% { opacity: 1; }
}
@keyframes brKp1 {
0%, 9% { transform: translateY(0); }
10% { transform: translateY(2px); }
14%, 100% { transform: translateY(0); }
}
@keyframes brKp2 {
0%, 34% { transform: translateY(0); }
35% { transform: translateY(2px); }
39%, 100% { transform: translateY(0); }
}
@keyframes brKp3 {
0%, 51% { transform: translateY(0); }
52% { transform: translateY(2px); }
56%, 100% { transform: translateY(0); }
}
@keyframes brKp4 {
0%, 71% { transform: translateY(0); }
72% { transform: translateY(2px); }
76%, 100% { transform: translateY(0); }
}
// ── 좁은 화면: 여백과 키 칸을 줄여 무대 안에 들어온다 (줄 높이 20px 는 그대로)
@media (max-width: 340px) {
.br { gap: $sp-1; padding: $sp-2; }
.br__wrap { height: $lh + 2 * $sp-2; }
.br__keys { gap: $sp-1; }
.br__k { height: 24px; font-size: 10px; }
.br__note { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.br.is-demo .br__txt, .br.is-demo .br__pp, .br.is-demo .br__caret,
.br.is-demo .br__pline, .br.is-demo .br__ping, .br.is-demo .br__sv,
.br.is-demo .br__kfill, .br.is-demo .br__k {
animation: none !important;
}
.br.is-demo .br__txt { clip-path: none !important; }
.br.is-demo .br__pp { opacity: 1 !important; transform: none !important; }
.br.is-demo .br__caret, .br.is-demo .br__pline, .br.is-demo .br__ping { opacity: 0 !important; }
.br.is-demo .br__sv { opacity: 0 !important; }
.br.is-demo .br__state { color: $stage-paper !important; }
.br.is-demo .br__kfill { opacity: 0 !important; }
}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);
}
.br {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.br__bar {
display: flex;
align-items: center;
gap: 8px;
}
.br__ico {
display: grid;
place-items: center;
color: #2f6df6;
font-size: 15px;
}
.br__name {
flex: 1 1 auto;
color: #17141a;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
}
.br__pill {
display: grid;
justify-items: center;
}
.br__pill > * {
grid-area: 1/1;
}
.br__state, .br__sv {
padding: 4px 8px;
border-radius: 999px;
background: #0a4fe8;
color: #ffffff;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
}
.br__sv {
opacity: 0;
}
.br__wrap {
position: relative;
display: grid;
overflow: hidden;
box-sizing: border-box;
height: 56px;
background: #ffffff;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 8px;
}
.br__wrap > * {
grid-area: 1/1;
}
.br__ta, .br__hl {
box-sizing: border-box;
margin: 0;
padding: 8px;
border: 0;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 15px;
line-height: 20px;
letter-spacing: 0;
tab-size: 2;
white-space: pre;
}
.br__hl {
overflow: hidden;
background: transparent;
color: #17141a;
}
.br__ln {
display: block;
}
.br__pp {
color: #0a4fe8;
font-weight: 700;
}
.br__ta {
background: transparent;
color: transparent;
caret-color: #17141a;
resize: none;
overflow: auto;
scrollbar-width: none;
}
.br__ta::-webkit-scrollbar {
display: none;
}
.br__ta::selection {
background: rgba(10, 79, 232, 0.2);
}
.br__ta:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: -2px;
}
.br__caret, .br__pline, .br__ping {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-size: 15px;
line-height: 20px;
pointer-events: none;
opacity: 0;
}
.br__caret {
position: absolute;
top: 8px;
left: 8px;
width: 2px;
height: 20px;
background: #17141a;
z-index: 2;
}
.br__pline {
position: absolute;
bottom: 8px;
left: calc(8px + 4ch);
width: 2ch;
height: 2px;
background: #0a4fe8;
transform-origin: left center;
z-index: 1;
}
.br__ping {
position: absolute;
top: 8px;
left: calc(8px + 4ch);
width: 2ch;
height: 20px;
border: 2px solid #0a4fe8;
border-radius: 4px;
z-index: 1;
}
.br__keys {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.br__k {
position: relative;
display: grid;
place-items: center;
overflow: hidden;
height: 26px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 4px;
background: rgba(23, 20, 26, 0.05);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
}
.br__kt {
color: rgba(23, 20, 26, 0.72);
}
.br__kfill {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #0a4fe8;
color: #ffffff;
opacity: 0;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.br__k.is-on .br__kfill {
opacity: 1;
}
.br__note {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.br.is-demo .br__txt {
animation-name: brType;
animation-duration: 2s;
animation-timing-function: steps(4, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.br.is-demo .br__pp {
animation-name: brPp;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.br.is-demo .br__caret {
animation-name: brCaretO, brCaretX;
animation-duration: 0.5s, 2s;
animation-timing-function: steps(1, end), ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.br.is-demo .br__pline {
animation-name: brUlO, brUlX;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.br.is-demo .br__ping {
animation-name: brPing;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.br.is-demo .br__state {
color: transparent;
}
.br.is-demo .br__sv {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.br.is-demo .br__sv--1 {
animation-name: brSv1;
}
.br.is-demo .br__sv--2 {
animation-name: brSv2;
}
.br.is-demo .br__sv--3 {
animation-name: brSv3;
}
.br.is-demo .br__kfill, .br.is-demo .br__k {
animation-duration: 2s;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.br.is-demo .br__kfill {
animation-timing-function: steps(1, end);
}
.br.is-demo .br__k {
animation-timing-function: ease-out;
transition: none;
}
.br.is-demo .br__k:nth-child(1) .br__kfill {
animation-name: brKf1;
}
.br.is-demo .br__k:nth-child(2) .br__kfill {
animation-name: brKf2;
}
.br.is-demo .br__k:nth-child(3) .br__kfill {
animation-name: brKf3;
}
.br.is-demo .br__k:nth-child(4) .br__kfill {
animation-name: brKf4;
}
.br.is-demo .br__k:nth-child(1) {
animation-name: brKp1;
}
.br.is-demo .br__k:nth-child(2) {
animation-name: brKp2;
}
.br.is-demo .br__k:nth-child(3) {
animation-name: brKp3;
}
.br.is-demo .br__k:nth-child(4) {
animation-name: brKp4;
}
@keyframes brType {
0%, 2% {
clip-path: inset(0 100% 0 0);
}
14%, 100% {
clip-path: inset(0 0 0 0);
}
}
@keyframes brPp {
0%, 9% {
opacity: 0;
transform: scale(1.35);
}
10%, 12% {
opacity: 1;
transform: scale(1.35);
}
13%, 59% {
opacity: 1;
transform: scale(1);
}
60%, 71% {
opacity: 0;
transform: scale(1);
}
72%, 74% {
opacity: 1;
transform: scale(1.35);
}
75%, 100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes brCaretO {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes brCaretX {
0%, 9% {
transform: translateX(4ch);
}
10%, 34% {
transform: translateX(5ch);
}
40%, 51% {
transform: translateX(6ch);
}
56%, 59% {
transform: translateX(5ch);
}
60%, 71% {
transform: translateX(4ch);
}
72%, 100% {
transform: translateX(5ch);
}
}
@keyframes brUlO {
0%, 9% {
opacity: 0;
}
10%, 59% {
opacity: 1;
}
60%, 71% {
opacity: 0;
}
72%, 100% {
opacity: 1;
}
}
@keyframes brUlX {
0%, 10% {
transform: scaleX(0);
}
16%, 59% {
transform: scaleX(1);
}
60%, 72% {
transform: scaleX(0);
}
78%, 100% {
transform: scaleX(1);
}
}
@keyframes brPing {
0%, 9% {
opacity: 0;
transform: scale(0.8);
}
10% {
opacity: 0.9;
transform: scale(0.8);
}
17%, 71% {
opacity: 0;
transform: scale(1.5);
}
72% {
opacity: 0.9;
transform: scale(0.8);
}
79%, 100% {
opacity: 0;
transform: scale(1.5);
}
}
@keyframes brSv1 {
0%, 9% {
opacity: 0;
}
10%, 34% {
opacity: 1;
}
35%, 71% {
opacity: 0;
}
72%, 100% {
opacity: 1;
}
}
@keyframes brSv2 {
0%, 34% {
opacity: 0;
}
35%, 51% {
opacity: 1;
}
52%, 100% {
opacity: 0;
}
}
@keyframes brSv3 {
0%, 51% {
opacity: 0;
}
52%, 71% {
opacity: 1;
}
72%, 100% {
opacity: 0;
}
}
@keyframes brKf1 {
0%, 9% {
opacity: 0;
}
10%, 34% {
opacity: 1;
}
35%, 100% {
opacity: 0;
}
}
@keyframes brKf2 {
0%, 34% {
opacity: 0;
}
35%, 51% {
opacity: 1;
}
52%, 100% {
opacity: 0;
}
}
@keyframes brKf3 {
0%, 51% {
opacity: 0;
}
52%, 71% {
opacity: 1;
}
72%, 100% {
opacity: 0;
}
}
@keyframes brKf4 {
0%, 71% {
opacity: 0;
}
72%, 100% {
opacity: 1;
}
}
@keyframes brKp1 {
0%, 9% {
transform: translateY(0);
}
10% {
transform: translateY(2px);
}
14%, 100% {
transform: translateY(0);
}
}
@keyframes brKp2 {
0%, 34% {
transform: translateY(0);
}
35% {
transform: translateY(2px);
}
39%, 100% {
transform: translateY(0);
}
}
@keyframes brKp3 {
0%, 51% {
transform: translateY(0);
}
52% {
transform: translateY(2px);
}
56%, 100% {
transform: translateY(0);
}
}
@keyframes brKp4 {
0%, 71% {
transform: translateY(0);
}
72% {
transform: translateY(2px);
}
76%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.br {
gap: 4px;
padding: 8px;
}
.br__wrap {
height: 36px;
}
.br__keys {
gap: 4px;
}
.br__k {
height: 24px;
font-size: 10px;
}
.br__note {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.br.is-demo .br__txt, .br.is-demo .br__pp, .br.is-demo .br__caret,
.br.is-demo .br__pline, .br.is-demo .br__ping, .br.is-demo .br__sv,
.br.is-demo .br__kfill, .br.is-demo .br__k {
animation: none !important;
}
.br.is-demo .br__txt {
clip-path: none !important;
}
.br.is-demo .br__pp {
opacity: 1 !important;
transform: none !important;
}
.br.is-demo .br__caret, .br.is-demo .br__pline, .br.is-demo .br__ping {
opacity: 0 !important;
}
.br.is-demo .br__sv {
opacity: 0 !important;
}
.br.is-demo .br__state {
color: #ffffff !important;
}
.br.is-demo .br__kfill {
opacity: 0 !important;
}
}var br = document.getElementById('br');
var ta = document.getElementById('ta');
var hl = document.getElementById('hl');
var wrap = document.getElementById('wrap');
var stateEl = document.getElementById('state');
var slots = [].slice.call(document.querySelectorAll('.br__k'));
var PAIRS = { '(': ')', '{': '}', '[': ']', '"': '"', "'": "'" };
function esc(t) {
return t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
function setState(label) { stateEl.textContent = label; }
// 키 기록 줄 — 최근 네 키가 오른쪽으로 밀려 마지막 칸이 켜진다
function logKey(k) {
for (var i = 0; i < slots.length - 1; i++) {
slots[i].querySelector('.br__kt').textContent = slots[i + 1].querySelector('.br__kt').textContent;
slots[i].querySelector('.br__kfill').textContent = slots[i + 1].querySelector('.br__kfill').textContent;
slots[i].classList.remove('is-on');
}
var last = slots[slots.length - 1];
last.querySelector('.br__kt').textContent = k;
last.querySelector('.br__kfill').textContent = k;
last.classList.add('is-on');
}
// 커서 왼쪽의 여는 짝과 오른쪽의 닫는 짝이 맞으면 그 두 글자에 색을 입힌다
function render() {
var v = ta.value;
var s = ta.selectionStart;
var open = -1;
if (s > 0 && PAIRS[v.charAt(s - 1)] === v.charAt(s)) open = s - 1;
var out = '', seg = '';
function flush() {
if (seg) { out += '<span class="br__txt">' + esc(seg) + '</span>'; seg = ''; }
}
for (var i = 0; i < v.length; i++) {
if (i === open || i === open + 1) {
flush();
out += '<span class="br__pp">' + esc(v.charAt(i)) + '</span>';
} else {
seg += v.charAt(i);
}
}
flush();
hl.innerHTML = '<span class="br__ln">' + (out || ' ') + '</span>';
// 줄이 늘어나 본문이 스크롤하면 아래 pre 도 같은 값으로 맞춘다
hl.scrollTop = ta.scrollTop;
}
ta.addEventListener('keydown', function (e) {
br.classList.remove('is-demo');
var k = e.key;
var v = ta.value;
var s = ta.selectionStart, t = ta.selectionEnd;
var over = s === t && v.charAt(s) === k && (PAIRS[k] === k || ')}]'.indexOf(k) >= 0);
if (PAIRS[k] && !over) {
// 여는 짝 — 닫는 짝을 같이 넣고 setSelectionRange 로 커서는 그 사이에 남긴다
e.preventDefault();
var inner = v.slice(s, t);
ta.setRangeText(k + inner + PAIRS[k], s, t, 'end');
ta.setSelectionRange(s + 1, s + 1 + inner.length);
setState('Pair added');
} else if (over) {
// 이미 닫혀 있는 자리 — 삽입하지 않고 커서만 넘어간다
e.preventDefault();
ta.setSelectionRange(s + 1, s + 1);
setState('Step over');
} else if (k === 'Backspace' && s === t && s > 0 && PAIRS[v.charAt(s - 1)] === v.charAt(s)) {
// 커서가 빈 짝 가운데서 Backspace — 두 글자를 함께 지운다
e.preventDefault();
ta.setRangeText('', s - 1, s + 1, 'end');
ta.setSelectionRange(s - 1, s - 1);
setState('Pair removed');
}
if (k.length === 1 || k === 'Backspace' || k === 'Enter' || k === 'Tab') logKey(k);
render();
});
ta.addEventListener('input', function () { br.classList.remove('is-demo'); render(); });
ta.addEventListener('keyup', render);
ta.addEventListener('click', render);
ta.addEventListener('focus', function () { br.classList.remove('is-demo'); render(); });
ta.addEventListener('scroll', function () { hl.scrollTop = ta.scrollTop; });
wrap.addEventListener('pointerdown', function () { br.classList.remove('is-demo'); });
render();
// 시작 커서는 짝 가운데 — 첫 화면부터 두 글자가 파랗게 쌍을 이루고 있다
ta.setSelectionRange(5, 5);// AutoCloseBrackets — vanilla 판(run/299/demo/03-editor-auto-close-brackets/index.html) 의 React 포트.
// 여는 짝을 치면 닫는 짝이 같이 들어가고 setSelectionRange 로 커서는 그 사이에 남는다.
// 이미 닫혀 있는 자리에서 닫는 글자를 치면 preventDefault 로 삽입을 막고 커서만 넘어가며,
// 빈 짝 가운데서 Backspace 를 치면 두 글자가 함께 지워진다.
// textarea 는 vanilla 처럼 비제어로 둔다 — setRangeText 가 값을 직접 바꾸므로 ref 로 읽어 다시 칠한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AutoCloseBrackets.module.scss";
export type AutoCloseBracketsProps = {
duration?: number;
easing?: string;
color?: string;
initialCode?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef<T> 는 T | null 을 돌려준다 — ambient 타입에 맞춰 상자 타입을 따로 둔다
type Box<T> = { current: T | null };
// ambient 타입에 React 이벤트 제네릭이 없다 — 쓰는 것만 로컬 타입으로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
const LABEL = "코드 입력칸";
const NOTE = "닫는 짝은 저절로 들어가고, 그 자리에서 한 번 더 치면 삽입 없이 지나갑니다";
const FILE_NAME = "auto-close.js";
const LBL_IDLE = "자동 닫기";
const LBL_INSERT = "짝 삽입";
const LBL_PASS = "통과";
const LBL_DROP = "짝 삭제";
// 미리보기에서 겹켜놓고 컷으로 갈아 끼우는 상태 값 장 — vanilla 와 같은 글자다
const STATE_VIEWS = [LBL_INSERT, LBL_PASS, LBL_DROP];
const KEYS = ["(", ")", "Backspace", "("];
const CODE = "fade()";
const PAIRS: { [k: string]: string } = { "(": ")", "{": "}", "[": "]", '"': '"', "'": "'" };
type Part = { text: string; pair: boolean };
// 커서 왼쪽의 여는 짝과 오른쪽의 닫는 짝이 맞으면 그 두 글자를 따로 떼어 색을 입힌다
function split(value: string, caret: number): Part[] {
let open = -1;
if (caret > 0 && PAIRS[value.charAt(caret - 1)] === value.charAt(caret)) open = caret - 1;
const out: Part[] = [];
let seg = "";
const flush = () => {
if (seg) {
out.push({ text: seg, pair: false });
seg = "";
}
};
for (let i = 0; i < value.length; i++) {
if (i === open || i === open + 1) {
flush();
out.push({ text: value.charAt(i), pair: true });
} else {
seg += value.charAt(i);
}
}
flush();
return out;
}
export default function AutoCloseBrackets(props: AutoCloseBracketsProps) {
const duration = props.duration ?? 220;
const easing = props.easing ?? "cubic-bezier(.2, 1.4, .4, 1)";
const color = props.color ?? "#0a4fe8";
const code = props.initialCode ?? CODE;
const [preview, setPreview] = useState<boolean>(true);
const [parts, setParts] = useState<Part[]>(split(code, 0));
const [label, setLabel] = useState<string>(LBL_IDLE);
const [keys, setKeys] = useState<string[]>(KEYS);
const [keyOn, setKeyOn] = useState<boolean>(false);
const taRef = useRef<HTMLTextAreaElement>(null) as Box<HTMLTextAreaElement>;
const hlRef = useRef<HTMLPreElement>(null) as Box<HTMLPreElement>;
const leave = () => setPreview(false);
// 키 기록 줄 — 최근 네 키가 오른쪽으로 밀려 마지막 칸이 켜진다
const logKey = (k: string) => {
setKeys((prev: string[]) => prev.slice(1).concat([k]));
setKeyOn(true);
};
const render = () => {
const ta = taRef.current;
const hl = hlRef.current;
if (!ta) return;
setParts(split(ta.value, ta.selectionStart));
// 줄이 늘어나 본문이 스크롤하면 아래 pre 도 같은 값으로 맞춘다
if (hl) hl.scrollTop = ta.scrollTop;
};
const onKeyDown = (e: KeyEvt) => {
leave();
const ta = taRef.current;
if (!ta) return;
const k = e.key;
const v = ta.value;
const s = ta.selectionStart;
const t = ta.selectionEnd;
const over = s === t && v.charAt(s) === k && (PAIRS[k] === k || ")}]".indexOf(k) >= 0);
if (PAIRS[k] && !over) {
// 여는 짝 — 닫는 짝을 같이 넣고 setSelectionRange 로 커서는 그 사이에 남긴다
e.preventDefault();
const inner = v.slice(s, t);
ta.setRangeText(k + inner + PAIRS[k], s, t, "end");
ta.setSelectionRange(s + 1, s + 1 + inner.length);
setLabel(LBL_INSERT);
} else if (over) {
// 이미 닫혀 있는 자리 — 삽입하지 않고 커서만 넘어간다
e.preventDefault();
ta.setSelectionRange(s + 1, s + 1);
setLabel(LBL_PASS);
} else if (k === "Backspace" && s === t && s > 0 && PAIRS[v.charAt(s - 1)] === v.charAt(s)) {
// 커서가 빈 짝 가운데서 Backspace — 두 글자를 함께 지운다
e.preventDefault();
ta.setRangeText("", s - 1, s + 1, "end");
ta.setSelectionRange(s - 1, s - 1);
setLabel(LBL_DROP);
}
if (k.length === 1 || k === "Backspace" || k === "Enter" || k === "Tab") logKey(k);
render();
};
useEffect(() => {
const ta = taRef.current;
if (!ta) return;
render();
// 시작 커서는 짝 가운데 — 손을 대지 않은 첫 화면에서 이어 치면 그 자리에서 시작한다
ta.setSelectionRange(5, 5);
}, []);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const last = keys.length - 1;
return (
<main className={styles.stage}>
<div className={styles.root + (preview ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M240 128c-64 0 0 88-64 88H80c-64 0 0-88-64-88c64 0 0-88 64-88h96c64 0 0 88 64 88" opacity=".2" />
<path d="M43.18 128a29.8 29.8 0 0 1 8 10.26c4.8 9.9 4.8 22 4.8 33.74c0 24.31 1 36 24 36a8 8 0 0 1 0 16c-17.48 0-29.32-6.14-35.2-18.26c-4.8-9.9-4.8-22-4.8-33.74c0-24.31-1-36-24-36a8 8 0 0 1 0-16c23 0 24-11.69 24-36c0-11.72 0-23.84 4.8-33.74C50.68 38.14 62.52 32 80 32a8 8 0 0 1 0 16c-23 0-24 11.69-24 36c0 11.72 0 23.84-4.8 33.74A29.8 29.8 0 0 1 43.18 128M240 120c-23 0-24-11.69-24-36c0-11.72 0-23.84-4.8-33.74C205.32 38.14 193.48 32 176 32a8 8 0 0 0 0 16c23 0 24 11.69 24 36c0 11.72 0 23.84 4.8 33.74a29.8 29.8 0 0 0 8 10.26a29.8 29.8 0 0 0-8 10.26c-4.8 9.9-4.8 22-4.8 33.74c0 24.31-1 36-24 36a8 8 0 0 0 0 16c17.48 0 29.32-6.14 35.2-18.26c4.8-9.9 4.8-22 4.8-33.74c0-24.31 1-36 24-36a8 8 0 0 0 0-16" />
</g>
</svg>
</span>
<span className={styles.name}>{FILE_NAME}</span>
<span className={styles.pill}>
<span className={styles.state}>{label}</span>
{STATE_VIEWS.map((text, i) => (
<span key={i} className={styles.sv + " " + styles["sv" + (i + 1)]} aria-hidden="true">
{text}
</span>
))}
</span>
</div>
<div className={styles.wrap} onPointerDown={leave}>
<pre className={styles.hl} ref={hlRef} aria-hidden="true">
<span className={styles.ln}>
{parts.length === 0
? " "
: parts.map((part, i) => (
<span key={i} className={part.pair ? styles.pp : styles.txt}>
{part.text}
</span>
))}
</span>
</pre>
<span className={styles.pline} aria-hidden="true" />
<span className={styles.ping} aria-hidden="true" />
<span className={styles.caret} aria-hidden="true" />
<textarea
className={styles.ta}
ref={taRef}
defaultValue={code}
spellCheck={false}
autoComplete="off"
wrap="off"
aria-label={LABEL}
onKeyDown={onKeyDown}
onChange={() => {
leave();
render();
}}
onKeyUp={render}
onClick={render}
onFocus={() => {
leave();
render();
}}
onScroll={() => {
const ta = taRef.current;
const hl = hlRef.current;
if (ta && hl) hl.scrollTop = ta.scrollTop;
}}
/>
</div>
<div className={styles.keys} aria-hidden="true">
{keys.map((text, i) => (
<span key={i} className={styles.k + (keyOn && i === last ? " " + styles.isOn : "")}>
<span className={styles.kt}>{text}</span>
<span className={styles.kfill}>{text}</span>
</span>
))}
</div>
<p className={styles.note}>{NOTE}</p>
</div>
</main>
);
}03 · Auto-close brackets — Measured area 7.31% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Insert, step over, delete the pair
var over = s === t && v.charAt(s) === k && (PAIRS[k] === k || ')}]'.indexOf(k) >= 0);
if (PAIRS[k] && !over) {
// Opening half — insert the closing partner too and let setSelectionRange leave the caret between them
e.preventDefault();
var inner = v.slice(s, t);
ta.setRangeText(k + inner + PAIRS[k], s, t, 'end');
ta.setSelectionRange(s + 1, s + 1 + inner.length);
setState('Pair added');
} else if (over) {
// Already closed at this spot — insert nothing, just move the caret past it
e.preventDefault();
ta.setSelectionRange(s + 1, s + 1);
setState('Step over');
} else if (k === 'Backspace' && s === t && s > 0 && PAIRS[v.charAt(s - 1)] === v.charAt(s)) {
// Backspace with the caret inside an empty pair — delete both characters together
e.preventDefault();
ta.setRangeText('', s - 1, s + 1, 'end');
ta.setSelectionRange(s - 1, s - 1);
setState('Pair removed');
}
Typing fade( leaves the value fade() with the caret at 5, and pressing ) there moves the caret to 6 without adding a character. The quote branch behaves the same way, and Backspace inside an empty pair takes both halves out, so a{} with the caret in the middle collapses to a.
04Tab indent and outdent
Tab inserts two spaces and Shift+Tab takes them back, and with several lines held the whole block moves while the selection survives.

<main class="stage">
<div class="ti is-demo" id="ti">
<div class="ti__bar">
<span class="ti__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 64v128H104V64Z" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8H104a8 8 0 0 1 0-16h112a8 8 0 0 1 8 8M104 72h112a8 8 0 0 0 0-16H104a8 8 0 0 0 0 16m112 112H104a8 8 0 0 0 0 16h112a8 8 0 0 0 0-16M43.58 55.16L48 52.94V104a8 8 0 0 0 16 0V40a8 8 0 0 0-11.58-7.16l-16 8a8 8 0 0 0 7.16 14.32m36.19 101.56a23.73 23.73 0 0 0-9.6-15.95a24.86 24.86 0 0 0-34.11 4.7a23.6 23.6 0 0 0-3.57 6.46a8 8 0 1 0 15 5.47a7.8 7.8 0 0 1 1.18-2.13a8.76 8.76 0 0 1 12-1.59a7.9 7.9 0 0 1 3.26 5.32a7.64 7.64 0 0 1-1.57 5.78a1 1 0 0 0-.08.11l-28.69 38.32A8 8 0 0 0 40 216h32a8 8 0 0 0 0-16H56l19.08-25.53a23.47 23.47 0 0 0 4.69-17.75"/></g></svg></span>
<span class="ti__name">deploy.yml</span>
<span class="ti__key ti__key--tab" aria-hidden="true">Tab</span>
<span class="ti__key ti__key--sft" aria-hidden="true">Shift+Tab</span>
</div>
<div class="ti__wrap">
<pre class="ti__ghost" id="ghost" aria-hidden="true"><span class="ti__ln"><span class="ti__tx"># deploy config</span></span><span class="ti__ln"><span class="ti__tx">deploy:</span></span><span class="ti__ln ti__ln--sel"><span class="ti__tx">build: scss</span></span><span class="ti__ln ti__ln--sel"><span class="ti__tx">minify: true</span></span><span class="ti__ln ti__ln--sel"><span class="ti__tx">upload: pages</span></span></pre>
<textarea class="ti__ta" id="ta" spellcheck="false" autocomplete="off" aria-label="Code input"># deploy config
deploy:
build: scss
minify: true
upload: pages</textarea>
</div>
<p class="ti__meta"><span class="ti__status" id="status" data-t-sel="lines {a} to {b} selected" data-t-caret="caret on line {n}"><span class="ti__stCaret">caret on line 1</span><span class="ti__stSel">lines 3 to 5 selected</span></span><span class="ti__lines" id="lines" data-t="{n} lines">5 lines</span></p>
<p class="ti__note">Select several lines and press Tab to move them together</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// Tab 들여쓰기와 되돌리기 — 잡힌 줄 전체가 같이 움직이고 선택 범위는 재계산되어 남는다.
// 실제 조작은 textarea 의 keydown Tab/Shift+Tab (JS). 격자 미리보기(.is-demo)는
// 선택 밴드가 steps(1, end) 컷으로 켜지고, 선택 줄의 글자가 두 칸씩 slide 로 밀렸다 빠진다.
// 무대가 옐로라 편집기 판은 잉크로 뒤집고 글자는 크림 — 옐로 면 위 크림 글자는 AA 미달 단골이라
// 이 데모의 글자는 전부 잉크 판 위에 앉는다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $stage-yellow;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$dim: #beb7ad;
$fs: clamp(11px, 3vw, 13px);
$lh: 20px;
$sel: rgba(255, 210, 63, .30);
@include stage($stage-yellow);
.ti {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border-radius: $r-card;
background: $stage-ink;
box-shadow: $shadow-raised;
}
// ── 파일 이름 줄
.ti__bar { display: flex; align-items: center; gap: $sp-2; }
.ti__ico { display: grid; place-items: center; color: $color; font-size: 15px; }
.ti__name {
flex: 1 1 auto; color: $subject-cream;
font-family: $mono; font-weight: 700; font-size: 12px; line-height: 1.4;
}
.ti__key {
flex: 0 0 auto; padding: $sp-1 $sp-2; border-radius: $r-pill;
border: 1px solid rgba(255, 255, 255, .24);
color: $dim; font-family: $mono; font-weight: 700; font-size: 10px; line-height: 1.4;
}
// ── 두 겹이 겹치는 자리. 미리보기용 pre 를 textarea 와 포갠다 (정적 경로에서는 pre 가 꺼져 있다)
.ti__wrap { position: relative; display: grid; height: $lh * 5 + $sp-4; }
.ti__wrap > * { grid-area: 1 / 1; }
%layer {
box-sizing: border-box; margin: 0; padding: $sp-2;
border: 0; border-radius: $r-sm;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
letter-spacing: 0; tab-size: 2; // 붙여 넣은 탭 문자도 두 칸 폭으로
white-space: pre; overflow: hidden;
}
.ti__ghost {
@extend %layer;
background: rgba(0, 0, 0, .28); color: $subject-cream;
opacity: 0; pointer-events: none;
}
.ti__ln { display: block; }
.ti__tx { display: inline-block; } // transform 이 inline 에는 안 걸리므로 블록화
.ti__ta {
@extend %layer;
background: transparent; color: $subject-cream; caret-color: $stage-yellow;
resize: none;
}
.ti__ta::selection { background: $sel; }
.ti__ta:focus-visible { outline: 2px solid $stage-yellow; outline-offset: -2px; }
.ti__meta { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; margin: 0; }
.ti__status { color: $dim; font-family: $mono; font-weight: 500; font-size: 11px; line-height: 1.4; }
.ti__lines {
flex: 0 0 auto; padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba(255, 210, 63, .18); color: $stage-yellow;
font-family: $mono; font-weight: 700; font-size: 11px; line-height: 1.4;
}
.ti__note {
margin: 0; color: $dim;
font-weight: 500; font-size: 11px; line-height: 1.4;
}
// ── 격자 미리보기 루프 (2초)
// 이야기: 6% 에 선택 밴드가 steps(1, end) 컷으로 켜지고(밴드 컷이 곧 포스터 — 줄 전체가 잡힌 장) →
// Tab 칩 점등(17%)과 함께 20→26% 두 칸 밀림 → 36→42% 다시 두 칸 →
// Shift+Tab 칩 점등(55%)과 함께 62%·72% 제자리. 복귀는 78% 안에 끝난다.
// 밴드를 0% 부터 고정하면 누적면적이 밴드 몫을 잃고 3% 대로 떨어진다(실측 299) — 컷이 면적을 지킨다.
// 밴드는 실제 편집기처럼 줄 전체 폭으로 깔리고 글자만 그 안에서 밀린다.
.ti.is-demo .ti__ta { color: transparent; -webkit-text-fill-color: transparent; }
.ti.is-demo .ti__ghost { opacity: 1; }
.ti.is-demo .ti__ln--sel {
animation-name: tiBand;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.ti.is-demo .ti__ln--sel .ti__tx {
animation-name: tiIndent;
animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
// 상태 문장도 밴드와 **같은 순간**에 갈아 끼운다 — 어긋나면 「1번 줄 커서 + 3~5줄 선택 밴드」 장면이
// poster 로 뽑힌다 (실측 2026-09-13: 문장을 JS 로만 쓰다 보니 미리보기에서 영원히 첫 문장이었다)
.ti__status { display: inline-grid; }
.ti__stCaret, .ti__stSel { grid-area: 1 / 1; }
.ti__stSel { visibility: hidden; }
.ti.is-demo .ti__stCaret { visibility: hidden; }
.ti.is-demo .ti__stSel { visibility: visible; }
.ti.is-demo .ti__key--tab {
animation-name: tiTabLit;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.ti.is-demo .ti__key--sft {
animation-name: tiSftLit;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
// steps(1, end) 는 구간 끝에서만 값을 바꾼다 — 점프 지점(6%)을 키프레임에 따로 둔다
@keyframes tiBand {
0%, 5% { background-color: rgba(255, 210, 63, 0); }
6%, 100% { background-color: $sel; }
}
// 2ch = 등폭 글자 두 칸 — 글자 크기가 좁은 화면에서 줄어도 밀림 폭이 글자와 같이 줄어든다
@keyframes tiIndent {
0%, 20% { transform: translateX(0); }
26%, 36% { transform: translateX(2ch); }
42%, 56% { transform: translateX(4ch); }
62%, 72% { transform: translateX(2ch); }
78%, 100% { transform: translateX(0); }
}
@keyframes tiTabLit {
0%, 17% { background-color: rgba(255, 210, 63, 0); color: $dim; border-color: rgba(255, 255, 255, .24); }
18%, 52% { background-color: $stage-yellow; color: $stage-ink; border-color: $stage-yellow; }
53%, 100% { background-color: rgba(255, 210, 63, 0); color: $dim; border-color: rgba(255, 255, 255, .24); }
}
@keyframes tiSftLit {
0%, 55% { background-color: rgba(255, 210, 63, 0); color: $dim; border-color: rgba(255, 255, 255, .24); }
56%, 84% { background-color: $stage-yellow; color: $stage-ink; border-color: $stage-yellow; }
85%, 100% { background-color: rgba(255, 210, 63, 0); color: $dim; border-color: rgba(255, 255, 255, .24); }
}
// ── 좁은 화면: 행간과 여백을 줄여 무대 안에 들어오게 한다 (2·3px 는 0 으로 내린다)
@media (max-width: 340px) {
.ti { gap: $sp-1; padding: $sp-2; }
.ti__wrap { height: 16px * 5 + $sp-2; }
.ti__ghost, .ti__ta { font-size: 11px; line-height: 16px; padding: $sp-1; }
.ti__name { font-size: 11px; }
.ti__key { font-size: 9px; padding: 0 $sp-1; }
.ti__ico { font-size: 13px; }
.ti__status, .ti__lines, .ti__note { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ti.is-demo .ti__ln--sel,
.ti.is-demo .ti__ln--sel .ti__tx,
.ti.is-demo .ti__key--tab,
.ti.is-demo .ti__key--sft {
animation: none !important;
transform: none !important;
background-color: transparent !important;
color: $dim !important;
border-color: rgba(255, 255, 255, .24) !important;
}
}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);
}
.ti {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ti__bar {
display: flex;
align-items: center;
gap: 8px;
}
.ti__ico {
display: grid;
place-items: center;
color: #ffd23f;
font-size: 15px;
}
.ti__name {
flex: 1 1 auto;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
}
.ti__key {
flex: 0 0 auto;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.24);
color: #beb7ad;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.ti__wrap {
position: relative;
display: grid;
height: 116px;
}
.ti__wrap > * {
grid-area: 1/1;
}
.ti__ta, .ti__ghost {
box-sizing: border-box;
margin: 0;
padding: 8px;
border: 0;
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
letter-spacing: 0;
tab-size: 2;
white-space: pre;
overflow: hidden;
}
.ti__ghost {
background: rgba(0, 0, 0, 0.28);
color: #fff7e6;
opacity: 0;
pointer-events: none;
}
.ti__ln {
display: block;
}
.ti__tx {
display: inline-block;
}
.ti__ta {
background: transparent;
color: #fff7e6;
caret-color: #ffd23f;
resize: none;
}
.ti__ta::selection {
background: rgba(255, 210, 63, 0.3);
}
.ti__ta:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: -2px;
}
.ti__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0;
}
.ti__status {
color: #beb7ad;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.ti__lines {
flex: 0 0 auto;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 210, 63, 0.18);
color: #ffd23f;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
}
.ti__note {
margin: 0;
color: #beb7ad;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.ti.is-demo .ti__ta {
color: transparent;
-webkit-text-fill-color: transparent;
}
.ti.is-demo .ti__ghost {
opacity: 1;
}
.ti.is-demo .ti__ln--sel {
animation-name: tiBand;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ti.is-demo .ti__ln--sel .ti__tx {
animation-name: tiIndent;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ti__status {
display: inline-grid;
}
.ti__stCaret, .ti__stSel {
grid-area: 1/1;
}
.ti__stSel {
visibility: hidden;
}
.ti.is-demo .ti__stCaret {
visibility: hidden;
}
.ti.is-demo .ti__stSel {
visibility: visible;
}
.ti.is-demo .ti__key--tab {
animation-name: tiTabLit;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ti.is-demo .ti__key--sft {
animation-name: tiSftLit;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes tiBand {
0%, 5% {
background-color: rgba(255, 210, 63, 0);
}
6%, 100% {
background-color: rgba(255, 210, 63, 0.3);
}
}
@keyframes tiIndent {
0%, 20% {
transform: translateX(0);
}
26%, 36% {
transform: translateX(2ch);
}
42%, 56% {
transform: translateX(4ch);
}
62%, 72% {
transform: translateX(2ch);
}
78%, 100% {
transform: translateX(0);
}
}
@keyframes tiTabLit {
0%, 17% {
background-color: rgba(255, 210, 63, 0);
color: #beb7ad;
border-color: rgba(255, 255, 255, 0.24);
}
18%, 52% {
background-color: #ffd23f;
color: #17141a;
border-color: #ffd23f;
}
53%, 100% {
background-color: rgba(255, 210, 63, 0);
color: #beb7ad;
border-color: rgba(255, 255, 255, 0.24);
}
}
@keyframes tiSftLit {
0%, 55% {
background-color: rgba(255, 210, 63, 0);
color: #beb7ad;
border-color: rgba(255, 255, 255, 0.24);
}
56%, 84% {
background-color: #ffd23f;
color: #17141a;
border-color: #ffd23f;
}
85%, 100% {
background-color: rgba(255, 210, 63, 0);
color: #beb7ad;
border-color: rgba(255, 255, 255, 0.24);
}
}
@media (max-width: 340px) {
.ti {
gap: 4px;
padding: 8px;
}
.ti__wrap {
height: 88px;
}
.ti__ghost, .ti__ta {
font-size: 11px;
line-height: 16px;
padding: 4px;
}
.ti__name {
font-size: 11px;
}
.ti__key {
font-size: 9px;
padding: 0 4px;
}
.ti__ico {
font-size: 13px;
}
.ti__status, .ti__lines, .ti__note {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ti.is-demo .ti__ln--sel,
.ti.is-demo .ti__ln--sel .ti__tx,
.ti.is-demo .ti__key--tab,
.ti.is-demo .ti__key--sft {
animation: none !important;
transform: none !important;
background-color: transparent !important;
color: #beb7ad !important;
border-color: rgba(255, 255, 255, 0.24) !important;
}
}var ti = document.getElementById('ti');
var ta = document.getElementById('ta');
var statusEl = document.getElementById('status');
var linesEl = document.getElementById('lines');
// 문구 템플릿은 화면 글자와 같은 근원(data-*)에서 읽는다 — 언어팩이 값을 바꿔도 조립 규칙이 함께 바뀐다
var TPL_SEL = statusEl.getAttribute('data-t-sel');
var TPL_CARET = statusEl.getAttribute('data-t-caret');
var TPL_LINES = linesEl.getAttribute('data-t');
var NL = '\n';
var INDENT = ' '; // 두 칸
function lineAt(v, pos) { return v.slice(0, pos).split(NL).length; }
function report() {
// 미리보기 루프 중에는 상태 칸을 건드리지 않는다 — 겹쳐 둔 두 문장을 textContent 가 통째로 지운다
if (ti.classList.contains('is-demo')) return;
var v = ta.value, s = ta.selectionStart, e = ta.selectionEnd;
if (e > s && v.slice(s, e).indexOf(NL) !== -1) {
statusEl.textContent = TPL_SEL.replace('{a}', lineAt(v, s)).replace('{b}', lineAt(v, e));
} else {
statusEl.textContent = TPL_CARET.replace('{n}', lineAt(v, s));
}
linesEl.textContent = TPL_LINES.replace('{n}', v.split(NL).length);
}
// 여러 줄 잡기: 잡힌 줄 전체가 같이 들여쓰기/내어쓰기되고 selectionStart/End 는 그 바뀐 만큼 다시 계산된다
function indentBlock(dir) {
var v = ta.value, s = ta.selectionStart, e = ta.selectionEnd;
var ls = v.lastIndexOf(NL, s - 1) + 1;
var ne = v.indexOf(NL, e);
if (ne === -1) ne = v.length;
var lines = v.slice(ls, ne).split(NL);
var firstShift = 0, lastShift = 0;
var moved = lines.map(function (ln, i) {
if (dir > 0) {
if (i === 0) firstShift = INDENT.length;
if (i === lines.length - 1) lastShift = INDENT.length;
return INDENT + ln;
}
var lead = ln.match(/^ {1,2}/);
var cut = lead ? lead[0].length : 0;
if (i === 0) firstShift = -cut;
if (i === lines.length - 1) lastShift = -cut;
return ln.slice(cut);
});
ta.value = v.slice(0, ls) + moved.join(NL) + v.slice(ne);
// 선택 범위 재계산: 시작은 첫 줄의 밀린 만큼, 끝은 마지막 줄의 밀린 만큼 따라간다
ta.setSelectionRange(Math.max(ls, s + firstShift), Math.max(ls + 1, e + lastShift));
}
ta.addEventListener('keydown', function (ev) {
if (ev.key !== 'Tab') return;
ev.preventDefault();
ti.classList.remove('is-demo');
var v = ta.value, s = ta.selectionStart, e = ta.selectionEnd;
if (v.slice(s, e).indexOf(NL) === -1) {
// 한 줄 안에서는 Tab 이 그 자리에 두 칸, Shift+Tab 이 줄 머리에서 두 칸을 지운다
if (!ev.shiftKey) {
ta.value = v.slice(0, s) + INDENT + v.slice(e);
ta.setSelectionRange(s + INDENT.length, s + INDENT.length);
} else {
var ls = v.lastIndexOf(NL, s - 1) + 1;
var cut = Math.min(INDENT.length, v.slice(ls, s).match(/ *$/)[0].length);
if (cut) {
ta.value = v.slice(0, s - cut) + v.slice(s);
ta.setSelectionRange(s - cut, s - cut);
}
}
} else {
indentBlock(ev.shiftKey ? -1 : 1);
}
report();
});
ta.addEventListener('input', report);
ta.addEventListener('keyup', report);
ta.addEventListener('mouseup', report);
ta.addEventListener('focus', function () { ti.classList.remove('is-demo'); });
ti.addEventListener('pointerdown', function () { ti.classList.remove('is-demo'); });
report();// TabIndentEditor — vanilla 판(run/299/demo/04-editor-tab-indent-selection/index.html) 의 React 포트.
// Tab 은 두 칸 들여쓰고 Shift+Tab 은 두 칸 되돌린다. 여러 줄을 잡고 누르면 잡힌 줄 전체가 같이 움직이고
// selectionStart/End 는 첫 줄·마지막 줄이 밀린 만큼 다시 계산되어 남는다 (vanilla 의 indentBlock 과 같은 식).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TabIndentEditor.module.scss";
export type TabIndentEditorProps = {
duration?: number;
easing?: string;
color?: string;
initialCode?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef<T> 는 T | null 을 돌려준다 — ambient 타입에 맞춰 상자 타입을 따로 둔다
type Box<T> = { current: T | null };
// ambient 에 React 이벤트 제네릭이 없다 — 쓰는 속성만 적은 로컬 타입을 파일 안에 둔다
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
type ChangeEvt = { target: { value: string; selectionStart: number; selectionEnd: number } };
const LABEL = "코드 입력칸";
const NOTE = "여러 줄을 잡고 Tab 을 누르면 줄 전체가 같이 움직입니다";
const FILE_NAME = "deploy.yml";
const KEY_TAB = "Tab";
const KEY_SHIFT_TAB = "Shift+Tab";
// 문구 템플릿은 화면 글자와 같은 근원에서 읽는다 — 언어팩이 값을 바꿔도 조립 규칙이 함께 바뀐다
const TPL_SEL = "{a}~{b}줄 선택";
const TPL_CARET = "{n}번 줄 커서";
const TPL_LINES = "{n}줄";
// 미리보기에 겹쳐 두는 두 문장. 밴드가 켜지는 순간 CSS 가 둘을 갈아 끼운다
const PREVIEW_CARET = "1번 줄 커서";
const PREVIEW_SEL = "3~5줄 선택";
const NL = "\n";
const INDENT = " "; // 두 칸
const CODE = ["# deploy config", "deploy:", "build: scss", "minify: true", "upload: pages"].join(NL);
// 미리보기 유령 판에서 선택 밴드가 깔리는 줄 (vanilla 의 ti__ln--sel 과 같은 자리)
const GHOST_SEL = [2, 3, 4];
type Edit = { value: string; start: number; end: number };
function lineAt(v: string, pos: number): number {
return v.slice(0, pos).split(NL).length;
}
// 여러 줄 잡기: 잡힌 줄 전체가 같이 들여쓰기/내어쓰기되고 selectionStart/End 는 그 바뀐 만큼 다시 계산된다
function indentBlock(dir: number, v: string, s: number, e: number): Edit {
const ls = v.lastIndexOf(NL, s - 1) + 1;
let ne = v.indexOf(NL, e);
if (ne === -1) ne = v.length;
const lines = v.slice(ls, ne).split(NL);
let firstShift = 0;
let lastShift = 0;
const moved = lines.map((ln, i) => {
if (dir > 0) {
if (i === 0) firstShift = INDENT.length;
if (i === lines.length - 1) lastShift = INDENT.length;
return INDENT + ln;
}
const lead = ln.match(/^ {1,2}/);
const cut = lead ? lead[0].length : 0;
if (i === 0) firstShift = -cut;
if (i === lines.length - 1) lastShift = -cut;
return ln.slice(cut);
});
// 선택 범위 재계산: 시작은 첫 줄의 밀린 만큼, 끝은 마지막 줄의 밀린 만큼 따라간다
return {
value: v.slice(0, ls) + moved.join(NL) + v.slice(ne),
start: Math.max(ls, s + firstShift),
end: Math.max(ls + 1, e + lastShift),
};
}
export default function TabIndentEditor(props: TabIndentEditorProps) {
const duration = props.duration ?? 220;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#ffd23f";
const [code, setCode] = useState<string>(props.initialCode ?? CODE);
const [sel, setSel] = useState<{ start: number; end: number }>({ start: 0, end: 0 });
const [preview, setPreview] = useState<boolean>(true);
const taRef = useRef<HTMLTextAreaElement>(null) as Box<HTMLTextAreaElement>;
// Tab 이 만든 다음 선택 범위. 값이 그려진 뒤에 setSelectionRange 로 놓아야 커서가 제자리에 선다
const pending = useRef<{ start: number; end: number }>(null) as Box<{ start: number; end: number }>;
useEffect(() => {
const ta = taRef.current;
const p = pending.current;
if (ta && p) {
ta.setSelectionRange(p.start, p.end);
pending.current = null;
}
}, [code]);
const report = () => {
const ta = taRef.current;
if (ta) setSel({ start: ta.selectionStart, end: ta.selectionEnd });
};
const onKeyDown = (ev: KeyEvt) => {
if (ev.key !== "Tab") return;
ev.preventDefault();
setPreview(false);
const ta = taRef.current;
if (!ta) return;
const v = ta.value;
const s = ta.selectionStart;
const e = ta.selectionEnd;
let next: Edit | null = null;
if (v.slice(s, e).indexOf(NL) === -1) {
// 한 줄 안에서는 Tab 이 그 자리에 두 칸, Shift+Tab 이 줄 머리에서 두 칸을 지운다
if (!ev.shiftKey) {
next = { value: v.slice(0, s) + INDENT + v.slice(e), start: s + INDENT.length, end: s + INDENT.length };
} else {
const ls = v.lastIndexOf(NL, s - 1) + 1;
const tail = v.slice(ls, s).match(/ *$/);
const cut = Math.min(INDENT.length, tail ? tail[0].length : 0);
if (cut) next = { value: v.slice(0, s - cut) + v.slice(s), start: s - cut, end: s - cut };
}
} else {
next = indentBlock(ev.shiftKey ? -1 : 1, v, s, e);
}
if (!next) return;
pending.current = { start: next.start, end: next.end };
setSel({ start: next.start, end: next.end });
setCode(next.value);
};
const status =
sel.end > sel.start && code.slice(sel.start, sel.end).indexOf(NL) !== -1
? TPL_SEL.replace("{a}", String(lineAt(code, sel.start))).replace("{b}", String(lineAt(code, sel.end)))
: TPL_CARET.replace("{n}", String(lineAt(code, sel.start)));
const lineCount = TPL_LINES.replace("{n}", String(code.split(NL).length));
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div className={styles.root + (preview ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M216 64v128H104V64Z" opacity=".2" />
<path d="M224 128a8 8 0 0 1-8 8H104a8 8 0 0 1 0-16h112a8 8 0 0 1 8 8M104 72h112a8 8 0 0 0 0-16H104a8 8 0 0 0 0 16m112 112H104a8 8 0 0 0 0 16h112a8 8 0 0 0 0-16M43.58 55.16L48 52.94V104a8 8 0 0 0 16 0V40a8 8 0 0 0-11.58-7.16l-16 8a8 8 0 0 0 7.16 14.32m36.19 101.56a23.73 23.73 0 0 0-9.6-15.95a24.86 24.86 0 0 0-34.11 4.7a23.6 23.6 0 0 0-3.57 6.46a8 8 0 1 0 15 5.47a7.8 7.8 0 0 1 1.18-2.13a8.76 8.76 0 0 1 12-1.59a7.9 7.9 0 0 1 3.26 5.32a7.64 7.64 0 0 1-1.57 5.78a1 1 0 0 0-.08.11l-28.69 38.32A8 8 0 0 0 40 216h32a8 8 0 0 0 0-16H56l19.08-25.53a23.47 23.47 0 0 0 4.69-17.75" />
</g>
</svg>
</span>
<span className={styles.name}>{FILE_NAME}</span>
<span className={styles.key + " " + styles.keyTab} aria-hidden="true">{KEY_TAB}</span>
<span className={styles.key + " " + styles.keySft} aria-hidden="true">{KEY_SHIFT_TAB}</span>
</div>
<div className={styles.wrap} onPointerDown={() => setPreview(false)}>
<pre className={styles.ghost} aria-hidden="true">
{CODE.split(NL).map((ln, i) => (
<span key={i} className={styles.ln + (GHOST_SEL.indexOf(i) !== -1 ? " " + styles.lnSel : "")}>
<span className={styles.tx}>{ln}</span>
</span>
))}
</pre>
<textarea
className={styles.ta}
ref={taRef}
value={code}
spellCheck={false}
autoComplete="off"
aria-label={LABEL}
onKeyDown={onKeyDown}
onChange={(e: ChangeEvt) => {
setPreview(false);
setSel({ start: e.target.selectionStart, end: e.target.selectionEnd });
setCode(e.target.value);
}}
onKeyUp={report}
onMouseUp={report}
onSelect={report}
onFocus={() => setPreview(false)}
/>
</div>
<p className={styles.meta}>
<span className={styles.status}>
{preview ? (
<>
<span className={styles.stCaret}>{PREVIEW_CARET}</span>
<span className={styles.stSel}>{PREVIEW_SEL}</span>
</>
) : (
status
)}
</span>
<span className={styles.lines}>{lineCount}</span>
</p>
<p className={styles.note}>{NOTE}</p>
</div>
</main>
);
}04 · Tab indent and outdent — Measured area 16.572% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Shifting every held line at once
// Multi-line hold: every held line is indented or outdented together, and selectionStart/End are recomputed by the amount each moved
function indentBlock(dir) {
var v = ta.value, s = ta.selectionStart, e = ta.selectionEnd;
var ls = v.lastIndexOf(NL, s - 1) + 1;
var ne = v.indexOf(NL, e);
if (ne === -1) ne = v.length;
var lines = v.slice(ls, ne).split(NL);
var firstShift = 0, lastShift = 0;
var moved = lines.map(function (ln, i) {
if (dir > 0) {
if (i === 0) firstShift = INDENT.length;
if (i === lines.length - 1) lastShift = INDENT.length;
return INDENT + ln;
}
var lead = ln.match(/^ {1,2}/);
var cut = lead ? lead[0].length : 0;
if (i === 0) firstShift = -cut;
if (i === lines.length - 1) lastShift = -cut;
return ln.slice(cut);
});
ta.value = v.slice(0, ls) + moved.join(NL) + v.slice(ne);
// Recompute the selection: the start follows the first line's shift, the end follows the last line's
ta.setSelectionRange(Math.max(ls, s + firstShift), Math.max(ls + 1, e + lastShift));
}
Holding lines 3 to 5 of the config sample, characters 24 to 62, and pressing Tab turned the leading spaces into 0, 0, 2, 2, 2 and carried the selection to 26 to 64. Shift+Tab put both back exactly — 0, 0, 0, 0, 0 and 24 to 62 — and pressing it once more with nothing left to remove changed neither the value nor the selection.
05Canvas minimap
A narrow canvas on the right paints line lengths as pixel bars instead of glyphs, the cheapest way to see the shape of a file that no longer fits the box.

<main class="stage">
<div class="mm is-demo" id="mm">
<div class="mm__bar">
<span class="mm__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><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></span>
<span class="mm__name">main.scss</span>
<span class="mm__status" id="status" data-t="viewing line {p}"><span class="mm__stTop">viewing line 1</span><span class="mm__stMid">The box and the text move together</span></span>
<span class="mm__count" id="count" data-t="{n} lines">30 lines</span>
</div>
<div class="mm__body">
<div class="mm__view" id="view" tabindex="0">
<pre class="mm__track" id="track"></pre>
</div>
<div class="mm__map" id="map" aria-label="Minimap: drag to move through the document">
<canvas class="mm__cv" id="cv"></canvas>
<span class="mm__box" id="box"></span>
</div>
</div>
<p class="mm__note">Drag the box and the text scrolls to that spot</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 캔버스 미니맵 — 오른쪽 좁은 canvas 가 줄 길이를 픽셀 띠(fillRect)로 그려 문서 전체 모양을 보여 준다.
// 캔버스는 로드 시 한 번만 그린다(렌더는 CSS 시계로 프레임을 뽑으므로 rAF 다시 그리기는 측정에 안 잡힌다).
// 상자 드래그와 본문 스크롤은 JS(Pointer Events + scroll)로 양방향으로 이어진다.
// 격자 미리보기(.is-demo)는 본문 트랙과 상자를 같은 리듬의 CSS 키프레임으로 움직인다 —
// 스크롤 상자(view)와 트랙(track)을 분리하고 transform 은 track 에만 건다(051 실측 함정 회피).
$duration: $dur-quick;
$easing: $ease-spring;
$color: $stage-yellow;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .14);
$dim: #beb7ad;
$fs: clamp(10px, 2.6vw, 12px);
$lh: 20px;
@include stage($stage-ink);
.mm {
// 미리보기 루프의 이동거리 — JS 가 실제 스크롤 범위에서 계산해 넣는다
--mm-track-y: -200px;
--mm-thumb-y: 53px;
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: $tick-panel;
box-shadow: $shadow-raised;
}
// ── 파일 이름 줄
.mm__bar { display: flex; align-items: center; gap: $sp-2; }
.mm__ico { display: grid; place-items: center; color: $color; font-size: 15px; }
.mm__name {
flex: 0 0 auto; color: $subject-cream;
font-family: $mono; font-weight: 700; font-size: 12px; line-height: 1.4;
}
.mm__status {
flex: 1 1 auto; color: $dim;
font-family: $mono; font-weight: 500; font-size: 10px; line-height: 1.4;
text-align: right;
}
.mm__count {
flex: 0 0 auto; padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba(255, 210, 63, .18); color: $stage-yellow;
font-family: $mono; font-weight: 700; font-size: 10px; line-height: 1.4;
}
// ── 본문(실제 스크롤) + 미니맵
.mm__body { display: flex; gap: $sp-2; }
.mm__view {
flex: 1 1 auto; height: $lh * 8;
overflow: auto; overscroll-behavior: contain;
border-radius: $r-sm; background: rgba(0, 0, 0, .28);
scrollbar-width: thin;
}
.mm__track {
box-sizing: border-box; margin: 0; padding: $sp-2;
color: $subject-cream;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
letter-spacing: 0; tab-size: 2;
white-space: pre;
}
.mm__ln { display: block; }
.mm__ln--dim { color: $dim; }
.mm__ln--tok { color: $subject-sky; }
.mm__map {
position: relative; flex: 0 0 48px; height: $lh * 8;
border-radius: $r-sm; background: rgba(0, 0, 0, .28);
overflow: hidden; cursor: grab; touch-action: none;
}
.mm__map:active { cursor: grabbing; }
.mm__cv { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.mm__box {
position: absolute; left: 0; right: 0; top: 0; box-sizing: border-box;
border: 2px solid $stage-yellow; border-radius: $r-xs;
background: rgba(255, 210, 63, .12);
}
.mm__note {
margin: 0; color: $dim;
font-weight: 500; font-size: 11px; line-height: 1.4;
}
// ── 격자 미리보기 루프 (2초)
// 본문 트랙이 10→50% 에 위로 스크롤되고 60→88% 에 제자리로 돌아온다(복귀는 90% 안에).
// 상자는 같은 키프레임 지점·같은 이징으로 따라 내려가고 올라온다 — 두 애니메이션이 같은 리듬으로 묶인다.
// 미리보기 동안에는 실제 스크롤 대신 트랙 transform 이 움직이게 뷰의 스크롤을 잠근다.
// 미리보기 문장에는 **줄 번호를 쓰지 않는다**. 트랙은 이징으로 연속 이동하는데 문장은 컷이라
// 어느 시점을 골라도 「1번째 줄」과 화면이 어긋난 장면이 poster 로 뽑힌다 (실측 2026-09-13).
// 숫자가 없는 문장으로 바꾸면 이동 중 어느 프레임에서도 참이다. 실제 조작 때는 JS 가 줄 번호를 쓴다
.mm__status { display: inline-grid; }
.mm__stTop, .mm__stMid { grid-area: 1 / 1; }
.mm__stMid { visibility: hidden; }
.mm.is-demo .mm__stTop {
animation-name: mmStTop; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.mm.is-demo .mm__stMid {
animation-name: mmStMid; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes mmStTop {
0%, 100% { visibility: hidden; }
}
@keyframes mmStMid {
0%, 100% { visibility: visible; }
}
.mm.is-demo .mm__view { overflow: hidden; }
.mm.is-demo .mm__track {
animation-name: mmScroll;
animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.mm.is-demo .mm__box {
animation-name: mmThumb;
animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes mmScroll {
0%, 10% { transform: translateY(0); }
50%, 60% { transform: translateY(var(--mm-track-y)); }
88%, 100% { transform: translateY(0); }
}
@keyframes mmThumb {
0%, 10% { transform: translateY(0); }
50%, 60% { transform: translateY(var(--mm-thumb-y)); }
88%, 100% { transform: translateY(0); }
}
// ── 좁은 화면: 행간과 여백을 줄여 무대 안에 들어오게 한다 (2·3px 는 0 으로 내린다)
@media (max-width: 340px) {
.mm { gap: $sp-1; padding: $sp-2; }
.mm__view, .mm__map { height: 104px; }
.mm__map { flex-basis: 40px; }
.mm__track { font-size: 10px; line-height: 16px; padding: $sp-1; }
.mm__name { font-size: 11px; }
.mm__ico { font-size: 13px; }
.mm__status, .mm__count, .mm__note { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.mm.is-demo .mm__stTop,
.mm.is-demo .mm__stMid,
.mm.is-demo .mm__track,
.mm.is-demo .mm__box {
animation: none !important;
transform: none !important;
}
}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);
}
.mm {
--mm-track-y: -200px;
--mm-thumb-y: 53px;
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 16px;
background: #231f28;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.mm__bar {
display: flex;
align-items: center;
gap: 8px;
}
.mm__ico {
display: grid;
place-items: center;
color: #ffd23f;
font-size: 15px;
}
.mm__name {
flex: 0 0 auto;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
}
.mm__status {
flex: 1 1 auto;
color: #beb7ad;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 10px;
line-height: 1.4;
text-align: right;
}
.mm__count {
flex: 0 0 auto;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 210, 63, 0.18);
color: #ffd23f;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.mm__body {
display: flex;
gap: 8px;
}
.mm__view {
flex: 1 1 auto;
height: 160px;
overflow: auto;
overscroll-behavior: contain;
border-radius: 8px;
background: rgba(0, 0, 0, 0.28);
scrollbar-width: thin;
}
.mm__track {
box-sizing: border-box;
margin: 0;
padding: 8px;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 20px;
letter-spacing: 0;
tab-size: 2;
white-space: pre;
}
.mm__ln {
display: block;
}
.mm__ln--dim {
color: #beb7ad;
}
.mm__ln--tok {
color: #6ec8ff;
}
.mm__map {
position: relative;
flex: 0 0 48px;
height: 160px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.28);
overflow: hidden;
cursor: grab;
touch-action: none;
}
.mm__map:active {
cursor: grabbing;
}
.mm__cv {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.mm__box {
position: absolute;
left: 0;
right: 0;
top: 0;
box-sizing: border-box;
border: 2px solid #ffd23f;
border-radius: 4px;
background: rgba(255, 210, 63, 0.12);
}
.mm__note {
margin: 0;
color: #beb7ad;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.mm__status {
display: inline-grid;
}
.mm__stTop, .mm__stMid {
grid-area: 1/1;
}
.mm__stMid {
visibility: hidden;
}
.mm.is-demo .mm__stTop {
animation-name: mmStTop;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mm.is-demo .mm__stMid {
animation-name: mmStMid;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes mmStTop {
0%, 100% {
visibility: hidden;
}
}
@keyframes mmStMid {
0%, 100% {
visibility: visible;
}
}
.mm.is-demo .mm__view {
overflow: hidden;
}
.mm.is-demo .mm__track {
animation-name: mmScroll;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mm.is-demo .mm__box {
animation-name: mmThumb;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes mmScroll {
0%, 10% {
transform: translateY(0);
}
50%, 60% {
transform: translateY(var(--mm-track-y));
}
88%, 100% {
transform: translateY(0);
}
}
@keyframes mmThumb {
0%, 10% {
transform: translateY(0);
}
50%, 60% {
transform: translateY(var(--mm-thumb-y));
}
88%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.mm {
gap: 4px;
padding: 8px;
}
.mm__view, .mm__map {
height: 104px;
}
.mm__map {
flex-basis: 40px;
}
.mm__track {
font-size: 10px;
line-height: 16px;
padding: 4px;
}
.mm__name {
font-size: 11px;
}
.mm__ico {
font-size: 13px;
}
.mm__status, .mm__count, .mm__note {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.mm.is-demo .mm__stTop,
.mm.is-demo .mm__stMid,
.mm.is-demo .mm__track,
.mm.is-demo .mm__box {
animation: none !important;
transform: none !important;
}
}var mm = document.getElementById('mm');
var view = document.getElementById('view');
var track = document.getElementById('track');
var map = document.getElementById('map');
var box = document.getElementById('box');
var cv = document.getElementById('cv');
var statusEl = document.getElementById('status');
var countEl = document.getElementById('count');
var TPL_VIEW = statusEl.getAttribute('data-t');
var TPL_LINES = countEl.getAttribute('data-t');
// 문서 30 lines — 미니맵 띠와 본문이 같은 배열에서 나온다
var LINES = [
'// layout tokens',
'$grid: 4px;',
'$radius: 12px;',
'',
'.panel {',
' display: grid;',
' grid-template-columns:',
' 1fr 1fr 1fr;',
' gap: $grid * 2;',
' padding: $grid * 3;',
' border-radius: $radius;',
'}',
'',
'.panel__title {',
' font-weight: 700;',
' font-size: 15px;',
' line-height: 20px;',
'}',
'',
'// motion',
'.panel__item {',
' transition: transform',
' 220ms ease;',
' transform: translateY(0);',
'}',
'.panel__item:hover {',
' transform: translateY(-4px);',
'}',
'',
'// end of file'
];
function esc(t) { return t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
function fillTrack() {
track.innerHTML = LINES.map(function (ln) {
var cls = ln.indexOf('//') === 0 ? ' mm__ln--dim' : (ln.trim().charAt(0) === '$' ? ' mm__ln--tok' : '');
return '<span class="mm__ln' + cls + '">' + esc(ln) + '</span>';
}).join('');
countEl.textContent = TPL_LINES.replace('{n}', LINES.length);
}
// 미니맵은 로드 시 한 번 그린다 — 렌더가 프레임을 뽑는 시계는 CSS 애니메이션이므로
// 캔버스를 매 프레임 다시 그리는 연출은 측정에 잡히지 않는다 (지시 299).
// devicePixelRatio 를 곱해 물리 픽셀에 맞춰 그려야 흐려지지 않는다.
function drawMap() {
var ctx = cv.getContext('2d');
var dpr = window.devicePixelRatio || 1;
var w = map.clientWidth, h = map.clientHeight;
cv.width = Math.max(1, Math.round(w * dpr));
cv.height = Math.max(1, Math.round(h * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var maxLen = 1;
LINES.forEach(function (ln) { maxLen = Math.max(maxLen, ln.trim().length); });
var pitch = h / LINES.length;
var barH = Math.max(1.5, pitch - 1.5);
LINES.forEach(function (ln, i) {
var bw = Math.max(2, (ln.trim().length / maxLen) * (w - 6));
ctx.fillStyle = ln.indexOf('//') === 0 ? 'rgba(190,183,173,.40)' : 'rgba(255,247,230,.78)';
ctx.fillRect(3, i * pitch, bw, barH);
});
}
function lineHeightPx() { return parseFloat(getComputedStyle(track).lineHeight) || 20; }
// 본문을 스크롤하면 상자가 따라온다 — 상자 위치는 scrollHeight 대비 비율로 계산한다
function placeBox() {
// 미리보기 루프 중에는 상태 칸을 건드리지 않는다 — 겹쳐 둔 두 문장을 textContent 가 통째로 지운다
if (mm.classList.contains('is-demo')) return;
var total = view.scrollHeight;
var shown = view.clientHeight;
var ratio = total > shown ? view.scrollTop / (total - shown) : 0;
box.style.height = Math.max(16, (shown / total) * map.clientHeight) + 'px';
var travel = Math.max(0, map.clientHeight - box.offsetHeight);
box.style.transform = 'translateY(' + (ratio * travel).toFixed(1) + 'px)';
var line = Math.min(LINES.length, Math.round(view.scrollTop / lineHeightPx()) + 1);
statusEl.textContent = TPL_VIEW.replace('{p}', line);
}
// 미리보기 루프의 이동거리도 실제 스크롤 범위에서 계산해 상자와 본문이 같은 비율로 움직이게 한다
function tuneLoop() {
var span = (view.scrollHeight - view.clientHeight) * 0.45;
var travel = Math.max(0, map.clientHeight - box.offsetHeight);
mm.style.setProperty('--mm-track-y', (-span).toFixed(0) + 'px');
mm.style.setProperty('--mm-thumb-y', (Math.abs(span) / Math.max(1, view.scrollHeight - view.clientHeight) * travel).toFixed(0) + 'px');
}
// 상자를 끌면(Pointer Events) 본문이 그 자리로 스크롤된다
var dragging = false;
function jumpTo(ev) {
var r = map.getBoundingClientRect();
var bh = box.offsetHeight;
var y = ev.clientY - r.top - bh / 2;
var k = Math.max(0, Math.min(1, y / Math.max(1, r.height - bh)));
view.scrollTop = k * (view.scrollHeight - view.clientHeight); // scroll 이벤트가 placeBox 를 부른다
}
map.addEventListener('pointerdown', function (ev) {
mm.classList.remove('is-demo');
dragging = true;
map.setPointerCapture(ev.pointerId);
jumpTo(ev);
ev.preventDefault();
});
map.addEventListener('pointermove', function (ev) { if (dragging) jumpTo(ev); });
function endDrag(ev) {
dragging = false;
try { map.releasePointerCapture(ev.pointerId); } catch (err) { /* 이미 풀린 포인터 */ }
}
map.addEventListener('pointerup', endDrag);
map.addEventListener('pointercancel', endDrag);
view.addEventListener('scroll', function () { mm.classList.remove('is-demo'); placeBox(); });
view.addEventListener('wheel', function () { mm.classList.remove('is-demo'); }, { passive: true });
view.addEventListener('focus', function () { mm.classList.remove('is-demo'); });
window.addEventListener('resize', function () { drawMap(); placeBox(); tuneLoop(); });
fillTrack();
drawMap();
placeBox();
tuneLoop();// MinimapEditor — vanilla 판(run/299/demo/05-editor-minimap-canvas/index.html) 의 React 포트.
// 오른쪽 좁은 canvas 가 줄 길이를 픽셀 띠(fillRect)로 그려 문서 전체 모양을 보여 준다.
// 캔버스는 마운트 때 한 번만 그린다 (매 프레임 다시 그리는 연출은 CSS 시계로 뽑는 측정에 안 잡힌다).
// 상자 드래그와 본문 스크롤은 Pointer Events + scroll 로 양방향으로 이어진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MinimapEditor.module.scss";
export type MinimapEditorProps = {
duration?: number;
easing?: string;
color?: string;
lines?: string[];
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef<T> 는 T | null 을 돌려준다 — ambient 타입에 맞춰 상자 타입을 따로 둔다
type Box<T> = { current: T | null };
// ambient 에 React 이벤트 제네릭이 없다 — 쓰는 속성만 적은 로컬 타입을 파일 안에 둔다
type PtrEvt = { clientY: number; pointerId: number; preventDefault(): void };
const FILE_NAME = "main.scss";
const MAP_LABEL = "미니맵 — 끌어서 문서 위치를 바꿉니다";
const NOTE = "상자를 끌면 본문이 그 자리로 스크롤됩니다";
// 문구 템플릿은 화면 글자와 같은 근원에서 읽는다 — 언어팩이 값을 바꿔도 조립 규칙이 함께 바뀐다
const TPL_VIEW = "{p}번째 줄 보는 중";
const TPL_LINES = "{n}줄";
// 미리보기에 겹쳐 두는 두 문장. 아래 문장에는 줄 번호를 쓰지 않는다 —
// 트랙은 이징으로 연속 이동하는데 문장은 컷이라 어느 시점을 골라도 숫자와 화면이 어긋난다
const PREVIEW_TOP = "1번째 줄 보는 중";
const PREVIEW_MID = "상자와 본문이 같이 움직입니다";
// 문서 30줄 — 미니맵 띠와 본문이 같은 배열에서 나온다
const LINES = [
"// layout tokens",
"$grid: 4px;",
"$radius: 12px;",
"",
".panel {",
" display: grid;",
" grid-template-columns:",
" 1fr 1fr 1fr;",
" gap: $grid * 2;",
" padding: $grid * 3;",
" border-radius: $radius;",
"}",
"",
".panel__title {",
" font-weight: 700;",
" font-size: 15px;",
" line-height: 20px;",
"}",
"",
"// motion",
".panel__item {",
" transition: transform",
" 220ms ease;",
" transform: translateY(0);",
"}",
".panel__item:hover {",
" transform: translateY(-4px);",
"}",
"",
"// end of file",
];
export default function MinimapEditor(props: MinimapEditorProps) {
const duration = props.duration ?? 220;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#ffd23f";
const lines = props.lines ?? LINES;
const [preview, setPreview] = useState<boolean>(true);
const [viewLine, setViewLine] = useState<number>(1);
const rootRef = useRef<HTMLDivElement>(null) as Box<HTMLDivElement>;
const viewRef = useRef<HTMLDivElement>(null) as Box<HTMLDivElement>;
const trackRef = useRef<HTMLPreElement>(null) as Box<HTMLPreElement>;
const mapRef = useRef<HTMLDivElement>(null) as Box<HTMLDivElement>;
const boxRef = useRef<HTMLSpanElement>(null) as Box<HTMLSpanElement>;
const cvRef = useRef<HTMLCanvasElement>(null) as Box<HTMLCanvasElement>;
const dragging = useRef<boolean>(false) as Box<boolean>;
// 미리보기 여부를 이벤트 안에서도 읽어야 한다 — resize·scroll 리스너가 낡은 값을 보지 않게 상자로 둔다
const previewRef = useRef<boolean>(true) as Box<boolean>;
const stopPreview = () => {
previewRef.current = false;
setPreview(false);
};
// devicePixelRatio 를 곱해 물리 픽셀에 맞춰 그려야 흐려지지 않는다
const drawMap = () => {
const cv = cvRef.current;
const map = mapRef.current;
if (!cv || !map) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = map.clientWidth;
const h = map.clientHeight;
cv.width = Math.max(1, Math.round(w * dpr));
cv.height = Math.max(1, Math.round(h * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
let maxLen = 1;
lines.forEach((ln) => {
maxLen = Math.max(maxLen, ln.trim().length);
});
const pitch = h / lines.length;
const barH = Math.max(1.5, pitch - 1.5);
lines.forEach((ln, i) => {
const bw = Math.max(2, (ln.trim().length / maxLen) * (w - 6));
ctx.fillStyle = ln.indexOf("//") === 0 ? "rgba(190,183,173,.40)" : "rgba(255,247,230,.78)";
ctx.fillRect(3, i * pitch, bw, barH);
});
};
const lineHeightPx = () => {
const track = trackRef.current;
if (!track) return 20;
return parseFloat(getComputedStyle(track).lineHeight) || 20;
};
// 본문을 스크롤하면 상자가 따라온다 — 상자 위치는 scrollHeight 대비 비율로 계산한다
const placeBox = () => {
// 미리보기 루프 중에는 상태 칸을 건드리지 않는다 — 겹쳐 둔 두 문장을 덮어쓰지 않게
if (previewRef.current) return;
const view = viewRef.current;
const map = mapRef.current;
const box = boxRef.current;
if (!view || !map || !box) return;
const total = view.scrollHeight;
const shown = view.clientHeight;
const ratio = total > shown ? view.scrollTop / (total - shown) : 0;
box.style.height = Math.max(16, (shown / total) * map.clientHeight) + "px";
const travel = Math.max(0, map.clientHeight - box.offsetHeight);
box.style.transform = "translateY(" + (ratio * travel).toFixed(1) + "px)";
setViewLine(Math.min(lines.length, Math.round(view.scrollTop / lineHeightPx()) + 1));
};
// 미리보기 루프의 이동거리도 실제 스크롤 범위에서 계산해 상자와 본문이 같은 비율로 움직이게 한다
const tuneLoop = () => {
const root = rootRef.current;
const view = viewRef.current;
const map = mapRef.current;
const box = boxRef.current;
if (!root || !view || !map || !box) return;
const span = (view.scrollHeight - view.clientHeight) * 0.45;
const travel = Math.max(0, map.clientHeight - box.offsetHeight);
root.style.setProperty("--mm-track-y", (-span).toFixed(0) + "px");
root.style.setProperty(
"--mm-thumb-y",
((Math.abs(span) / Math.max(1, view.scrollHeight - view.clientHeight)) * travel).toFixed(0) + "px",
);
};
useEffect(() => {
drawMap();
placeBox();
tuneLoop();
const onResize = () => {
drawMap();
placeBox();
tuneLoop();
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// 상자를 끌면 본문이 그 자리로 스크롤된다
const jumpTo = (ev: PtrEvt) => {
const map = mapRef.current;
const view = viewRef.current;
const box = boxRef.current;
if (!map || !view || !box) return;
const r = map.getBoundingClientRect();
const bh = box.offsetHeight;
const y = ev.clientY - r.top - bh / 2;
const k = Math.max(0, Math.min(1, y / Math.max(1, r.height - bh)));
view.scrollTop = k * (view.scrollHeight - view.clientHeight); // scroll 이벤트가 placeBox 를 부른다
};
const onMapDown = (ev: PtrEvt) => {
stopPreview();
dragging.current = true;
const map = mapRef.current;
if (map) map.setPointerCapture(ev.pointerId);
jumpTo(ev);
ev.preventDefault();
};
const endDrag = (ev: PtrEvt) => {
dragging.current = false;
const map = mapRef.current;
if (!map) return;
try {
map.releasePointerCapture(ev.pointerId);
} catch (err) {
/* 이미 풀린 포인터 */
}
};
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div className={styles.root + (preview ? " " + styles.isDemo : "")} style={vars} ref={rootRef}>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<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>
</span>
<span className={styles.name}>{FILE_NAME}</span>
<span className={styles.status}>
{preview ? (
<>
<span className={styles.stTop}>{PREVIEW_TOP}</span>
<span className={styles.stMid}>{PREVIEW_MID}</span>
</>
) : (
TPL_VIEW.replace("{p}", String(viewLine))
)}
</span>
<span className={styles.count}>{TPL_LINES.replace("{n}", String(lines.length))}</span>
</div>
<div className={styles.body}>
<div
className={styles.view}
ref={viewRef}
tabIndex={0}
onScroll={() => {
stopPreview();
placeBox();
}}
onWheel={stopPreview}
onFocus={stopPreview}
>
<pre className={styles.track} ref={trackRef}>
{lines.map((ln, i) => (
<span
key={i}
className={
styles.ln +
(ln.indexOf("//") === 0
? " " + styles.lnDim
: ln.trim().charAt(0) === "$"
? " " + styles.lnTok
: "")
}
>
{ln}
</span>
))}
</pre>
</div>
<div
className={styles.map}
ref={mapRef}
aria-label={MAP_LABEL}
onPointerDown={onMapDown}
onPointerMove={(ev: PtrEvt) => {
if (dragging.current) jumpTo(ev);
}}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<canvas className={styles.cv} ref={cvRef} />
<span className={styles.box} ref={boxRef} />
</div>
</div>
<p className={styles.note}>{NOTE}</p>
</div>
</main>
);
}05 · Canvas minimap — Measured area 15.017% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Bars measured, not text drawn
var pitch = h / LINES.length;
var barH = Math.max(1.5, pitch - 1.5);
LINES.forEach(function (ln, i) {
var bw = Math.max(2, (ln.trim().length / maxLen) * (w - 6));
ctx.fillStyle = ln.indexOf('//') === 0 ? 'rgba(190,183,173,.40)' : 'rgba(255,247,230,.78)';
ctx.fillRect(3, i * pitch, bw, barH);
});
Each bar is a trimmed line length scaled against the longest line, and the 30-line sample comes out as 2,452 painted pixels with no glyph rendering at all. Dragging the viewport box from top to bottom walks scrollTop from 0 to 376 in a 536-pixel document, and scrolling the text to 120 puts the box at translateY(35.7px) on the way back.
06Find and replace bar
Ctrl+F drops the bar, every match takes a yellow band, and the counter tells you which match you are on out of how many.

<main class="stage">
<div class="fr is-demo" id="fr">
<div class="fr__bar">
<span class="fr__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<span class="fr__name">tokens.scss</span>
<span class="fr__hint">Ctrl+F</span>
</div>
<div class="fr__wrap" id="wrap">
<div class="fr__find" id="find" role="group" aria-label="Find and replace">
<div class="fr__row">
<span class="fr__fico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="fr__q" id="q" type="text" value="4px" placeholder="Find what" aria-label="Find what" spellcheck="false" autocomplete="off">
<span class="fr__count" id="count" aria-live="polite"><span class="fr__cnum fr__cnum--a">3/6</span><span class="fr__cnum fr__cnum--b">5/6</span></span>
<button class="fr__nav" id="prev" type="button" aria-label="Previous match"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M160 48v160l-80-80Z" opacity=".2"/><path d="M163.06 40.61a8 8 0 0 0-8.72 1.73l-80 80a8 8 0 0 0 0 11.32l80 80A8 8 0 0 0 168 208V48a8 8 0 0 0-4.94-7.39M152 188.69L91.31 128L152 67.31Z"/></g></svg></button>
<button class="fr__nav" id="next" type="button" aria-label="Next match"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg></button>
<button class="fr__nav" id="close" type="button" aria-label="Close"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg></button>
</div>
<div class="fr__row">
<input class="fr__r" id="rep" type="text" value="8px" placeholder="Replace with" aria-label="Replace with" spellcheck="false" autocomplete="off">
<button class="fr__go" id="go" type="button">Replace</button>
</div>
</div>
<pre class="fr__hl" id="hl" aria-hidden="true">/* spacing scale — <mark class="fr__m">4px</mark> grid */
$sp-1: <mark class="fr__m">4px</mark>;
$sp-2: 8px;
.card {
padding: <mark class="fr__m">4px</mark> 12px;
gap: <mark class="fr__m">4px</mark>;
border-radius: <mark class="fr__m">4px</mark>;
}
.chip { margin: <mark class="fr__m">4px</mark>; }</pre>
<textarea class="fr__ta" id="ta" spellcheck="false" autocomplete="off" wrap="off" aria-label="Code input">/* spacing scale — 4px grid */
$sp-1: 4px;
$sp-2: 8px;
.card {
padding: 4px 12px;
gap: 4px;
border-radius: 4px;
}
.chip { margin: 4px; }</textarea>
</div>
<p class="fr__note">Enter moves to the next match, Replace swaps only the current one</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 찾기·바꾸기 바 — Ctrl+F 로 바가 내려오고, 맞는 자리마다 오버레이 pre 안의 mark 로 노란 표시가 깔린다.
// 몇 번째 중 몇 개인지 세어 주고(n/개수) Enter 로 다음 자리로 넘어가며, 바꾸기는 지금 자리만 바꾼다.
// 표시는 늘 오버레이 쪽이다 — textarea 의 글자는 투명하고(01 실시간 구문 강조와 같은 두 겹 기법)
// matchAll 로 뽑은 구간을 mark 로 감싸 다시 칠한다. 현재 자리만 주황으로 갈라 본다.
// 노란 면·주황 면 위 글자는 잉크다 — 크림을 얹으면 대비 미달이다.
// 격자 미리보기(.is-demo)는 바를 steps(1, end) 컷 + translateY 팝으로 내리고,
// 보이는 줄의 마크 3개를 한 개씩 깐 뒤 현재 자리(주황)가 3번 에서 5번 으로 옮겨 간다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $stage-yellow;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$dim: #beb7ad;
$fs: clamp(11px, 3vw, 13px);
$lh: 20px;
@include stage($stage-ink);
// 격자 미리보기 루프 공통 — 낱개 속성만 쓴다(단축은 delay 를 지운다), 시작 전은 0% 상태로 채운다
@mixin loop($name, $timing) {
animation-name: $name;
animation-duration: $dur-loop;
animation-timing-function: $timing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fr {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .05);
}
// ── 파일 이름 줄
.fr__bar { display: flex; align-items: center; gap: $sp-2; }
.fr__ico { display: grid; place-items: center; color: $color; font-size: 15px; }
.fr__name {
flex: 1 1 auto; color: $subject-cream;
font-family: $mono; font-weight: 700; font-size: 12px; line-height: 1.4;
}
.fr__hint {
flex: 0 0 auto; padding: $sp-1 $sp-2; border: 1px solid $line; border-radius: $r-pill;
color: $dim; font-family: $mono; font-weight: 700; font-size: 10px; line-height: 1.4;
}
// ── 두 겹이 겹치는 자리 + 내려오는 찾기 바
// 트랙을 minmax(0, 1fr) 로 못박는다 — auto 트랙은 textarea 내용 줄 수만큼 커져 무대를 넘긴다
.fr__wrap { position: relative; display: grid; grid-template-rows: minmax(0, 1fr); height: $lh * 7 + $sp-4; }
.fr__hl, .fr__ta { grid-area: 1 / 1; }
%layer {
box-sizing: border-box; margin: 0; padding: $sp-2;
border: 0; border-radius: $r-sm;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
letter-spacing: 0; tab-size: 2;
white-space: pre; overflow: auto; overscroll-behavior: contain;
scrollbar-width: none;
}
.fr__hl::-webkit-scrollbar, .fr__ta::-webkit-scrollbar { display: none; }
.fr__hl {
@extend %layer;
background: rgba(0, 0, 0, .28); color: $subject-cream;
pointer-events: none;
}
// 글자는 투명하고 캐럿만 남긴다 — 색과 표시는 위 pre 가 그린다
.fr__ta {
@extend %layer;
background: transparent; color: transparent; caret-color: $color;
resize: none;
-webkit-text-fill-color: transparent;
}
.fr__ta::selection { background: rgba(255, 210, 63, .32); }
.fr__ta:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
// ── 매치 표시. 닫힌 상태에서는 투명 — 바가 열려야(is-find) 노란다
.fr__m { background: transparent; color: inherit; border-radius: $r-xs; }
.fr.is-find .fr__m { background: $color; color: $subject-ink; }
.fr.is-find .fr__m--cur { background: $stage-orange; color: $subject-ink; }
// ── 찾기 바. 절대배치로 코드 위에 떠서 줄을 밀어내지 않는다 (VS Code 의 창 위젯과 같은 결)
.fr__find {
position: absolute; top: 0; left: 0; right: 0; z-index: 2;
display: flex; flex-direction: column; gap: $sp-1;
padding: $sp-2; border: 1px solid $line; border-radius: $r-control;
background: $tick-panel; box-shadow: $shadow-raised;
opacity: 0; visibility: hidden; transform: translateY(-12px); pointer-events: none;
transition: opacity $duration $ease-out, transform $duration $easing, visibility $duration;
}
.fr.is-find .fr__find { opacity: 1; visibility: visible; transform: translateY(0); pointer-events: auto; }
.fr__row { display: flex; align-items: center; gap: $sp-1; }
.fr__fico { display: grid; place-items: center; flex: 0 0 auto; color: $color; font-size: 14px; }
.fr__q, .fr__r {
flex: 1 1 auto; min-width: 0; box-sizing: border-box; height: 26px;
padding: 0 $sp-2; border: 1px solid $line; border-radius: $r-sm;
background: rgba(0, 0, 0, .35); color: $subject-cream;
font-family: $mono; font-weight: 500; font-size: 12px; line-height: 24px;
}
.fr__q::placeholder, .fr__r::placeholder { color: $dim; opacity: 1; }
.fr__q:focus-visible, .fr__r:focus-visible { outline: 2px solid $subject-blue; outline-offset: 1px; }
// 몇 번째 중 몇 개 — 두 숫자 겹을 그리드 한 칸에 포갠다(미리보기에서 상태를 컷으로 갈아 낀다)
.fr__count {
position: relative; flex: 0 0 auto; min-width: 40px; height: 26px;
display: grid; place-items: center;
border-radius: $r-pill; background: rgba(0, 0, 0, .35); color: $color;
font-family: $mono; font-weight: 700; font-size: 11px; line-height: 1.4;
}
.fr__cnum { grid-area: 1 / 1; opacity: 0; }
.fr__nav {
flex: 0 0 auto; display: grid; place-items: center;
width: 26px; height: 26px; box-sizing: border-box;
padding: 0; border: 1px solid $line; border-radius: $r-sm;
background: transparent; color: $subject-cream; font-size: 14px; cursor: pointer;
}
.fr__nav:hover, .fr__nav:focus-visible { background: rgba(255, 255, 255, .12); }
.fr__go {
flex: 0 0 auto; height: 26px; padding: 0 $sp-3;
border: 0; border-radius: $r-sm;
background: $subject-blue-deep; color: $subject-cream; // 깊은 파랑 면 위 크림
font-weight: 700; font-size: 11px; line-height: 1.4; cursor: pointer;
}
.fr__go:hover, .fr__go:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.fr__note {
margin: 0; color: $dim;
font-weight: 500; font-size: 11px; line-height: 1.4;
}
// ── 격자 미리보기 루프 (2초)
// 매치 표시 3개(padding·gap·border-radius 줄)는 처음부터 깔려 있고 첫 표시가 곧 현재 자리(주황),
// 카운터는 3/6 이다 — 첫 샘플 프레임이 완성 상태라 poster 가 빈 장면이 되지 않는다. 3% 에 바가 컷으로 내려온다(팝은 14% 까지).
// 58% 에 현재 자리가 3번 에서 5번 으로 옮겨가고(Enter) 카운터가 5/6 이 되며, 70~84% 바꾸기 단추가 한 번 부풀고 유지.
// 퇴장은 찍지 않는다 — 루프 경계에서만 되돌아간다.
.fr.is-demo .fr__find {
animation-name: frBarVis, frBarPop;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
// 막대 아래(1·2번 줄)와 접힌 줄(9번 줄)의 마크는 보이지 않으니 굳이 움직이지 않는다
.fr.is-demo .fr__m { background: $color; color: $subject-ink; }
.fr.is-demo .fr__hl mark:nth-of-type(3) { @include loop(frM3, steps(1, end)); }
.fr.is-demo .fr__hl mark:nth-of-type(4) { @include loop(frM4, steps(1, end)); }
.fr.is-demo .fr__hl mark:nth-of-type(5) { @include loop(frM5, steps(1, end)); }
.fr.is-demo .fr__cnum--a { @include loop(frCntA, steps(1, end)); }
.fr.is-demo .fr__cnum--b { @include loop(frCntB, steps(1, end)); }
.fr.is-demo .fr__go { @include loop(frGoPulse, $easing); }
@keyframes frBarVis {
0% { opacity: 0; visibility: hidden; }
3%, 100% { opacity: 1; visibility: visible; }
}
@keyframes frBarPop {
0%, 3% { transform: translateY(-12px); }
14%, 100% { transform: translateY(0); }
}
// steps(1, end) 컷 — 반투명 유령 프레임 없이 한 프레임에 상태가 바뀐다
// 매치 표시는 **첫 프레임부터 켜 둔다**. 켜지는 순간을 루프 안에 두면 24장 샘플(4.1667% 간격)이
// 그 구간을 못 잡아 대표 이미지가 「바만 있고 표시가 없는」 장면이 된다 (실측 2026-09-13:
// 3.5% 컷을 줬는데 프레임 0·1 의 mark 배경이 둘 다 투명이었다). 움직임은 현재 자리 이동으로 만든다.
@keyframes frM3 {
0%, 57% { background: $stage-orange; color: $subject-ink; }
58%, 100% { background: $color; color: $subject-ink; }
}
@keyframes frM4 {
0%, 100% { background: $color; color: $subject-ink; }
}
@keyframes frM5 {
0%, 57% { background: $color; color: $subject-ink; }
58%, 100% { background: $stage-orange; color: $subject-ink; }
}
@keyframes frCntA {
0%, 57% { opacity: 1; }
58%, 100% { opacity: 0; }
}
@keyframes frCntB {
0%, 57% { opacity: 0; }
58%, 100% { opacity: 1; }
}
@keyframes frGoPulse {
0%, 70% { transform: scale(1); }
76% { transform: scale(1.14); }
84%, 100% { transform: scale(1); }
}
// ── 좁은 화면: 행간과 여백을 줄여 무대 안에 들어오게 한다
@media (max-width: 340px) {
.fr { gap: $sp-1; padding: $sp-2; }
.fr__wrap { height: 16px * 6 + $sp-2; }
.fr__hl, .fr__ta { font-size: 11px; line-height: 16px; padding: $sp-1; }
.fr__note { font-size: 10px; }
.fr__find { gap: $sp-1; padding: $sp-1; }
.fr__q, .fr__r { height: 24px; font-size: 11px; line-height: 22px; }
.fr__count { height: 24px; min-width: 36px; font-size: 10px; }
.fr__nav { width: 24px; height: 24px; font-size: 12px; }
.fr__go { height: 24px; padding: 0 $sp-2; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.fr.is-demo .fr__find,
.fr.is-demo .fr__hl mark,
.fr.is-demo .fr__cnum,
.fr.is-demo .fr__go {
animation: none !important;
opacity: 1 !important;
visibility: visible !important;
transform: none !important;
}
// 카운터 두 겹이 겹치지 않게 앞 숫자만 보이게 한다
.fr.is-demo .fr__cnum--b { opacity: 0 !important; }
}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);
}
.fr {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
}
.fr__bar {
display: flex;
align-items: center;
gap: 8px;
}
.fr__ico {
display: grid;
place-items: center;
color: #ffd23f;
font-size: 15px;
}
.fr__name {
flex: 1 1 auto;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
}
.fr__hint {
flex: 0 0 auto;
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
color: #beb7ad;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.fr__wrap {
position: relative;
display: grid;
grid-template-rows: minmax(0, 1fr);
height: 156px;
}
.fr__hl, .fr__ta {
grid-area: 1/1;
}
.fr__ta, .fr__hl {
box-sizing: border-box;
margin: 0;
padding: 8px;
border: 0;
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
letter-spacing: 0;
tab-size: 2;
white-space: pre;
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.fr__hl::-webkit-scrollbar, .fr__ta::-webkit-scrollbar {
display: none;
}
.fr__hl {
background: rgba(0, 0, 0, 0.28);
color: #fff7e6;
pointer-events: none;
}
.fr__ta {
background: transparent;
color: transparent;
caret-color: #ffd23f;
resize: none;
-webkit-text-fill-color: transparent;
}
.fr__ta::selection {
background: rgba(255, 210, 63, 0.32);
}
.fr__ta:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.fr__m {
background: transparent;
color: inherit;
border-radius: 4px;
}
.fr.is-find .fr__m {
background: #ffd23f;
color: #17141a;
}
.fr.is-find .fr__m--cur {
background: #ff4d1f;
color: #17141a;
}
.fr__find {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 2;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 12px;
background: #231f28;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
visibility: hidden;
transform: translateY(-12px);
pointer-events: none;
transition: opacity 220ms ease-out, transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), visibility 220ms;
}
.fr.is-find .fr__find {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.fr__row {
display: flex;
align-items: center;
gap: 4px;
}
.fr__fico {
display: grid;
place-items: center;
flex: 0 0 auto;
color: #ffd23f;
font-size: 14px;
}
.fr__q, .fr__r {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
height: 26px;
padding: 0 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
background: rgba(0, 0, 0, 0.35);
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 12px;
line-height: 24px;
}
.fr__q::placeholder, .fr__r::placeholder {
color: #beb7ad;
opacity: 1;
}
.fr__q:focus-visible, .fr__r:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.fr__count {
position: relative;
flex: 0 0 auto;
min-width: 40px;
height: 26px;
display: grid;
place-items: center;
border-radius: 999px;
background: rgba(0, 0, 0, 0.35);
color: #ffd23f;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
}
.fr__cnum {
grid-area: 1/1;
opacity: 0;
}
.fr__nav {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 26px;
height: 26px;
box-sizing: border-box;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
background: transparent;
color: #fff7e6;
font-size: 14px;
cursor: pointer;
}
.fr__nav:hover, .fr__nav:focus-visible {
background: rgba(255, 255, 255, 0.12);
}
.fr__go {
flex: 0 0 auto;
height: 26px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.fr__go:hover, .fr__go:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.fr__note {
margin: 0;
color: #beb7ad;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.fr.is-demo .fr__find {
animation-name: frBarVis, frBarPop;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.fr.is-demo .fr__m {
background: #ffd23f;
color: #17141a;
}
.fr.is-demo .fr__hl mark:nth-of-type(3) {
animation-name: frM3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fr.is-demo .fr__hl mark:nth-of-type(4) {
animation-name: frM4;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fr.is-demo .fr__hl mark:nth-of-type(5) {
animation-name: frM5;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fr.is-demo .fr__cnum--a {
animation-name: frCntA;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fr.is-demo .fr__cnum--b {
animation-name: frCntB;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fr.is-demo .fr__go {
animation-name: frGoPulse;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes frBarVis {
0% {
opacity: 0;
visibility: hidden;
}
3%, 100% {
opacity: 1;
visibility: visible;
}
}
@keyframes frBarPop {
0%, 3% {
transform: translateY(-12px);
}
14%, 100% {
transform: translateY(0);
}
}
@keyframes frM3 {
0%, 57% {
background: #ff4d1f;
color: #17141a;
}
58%, 100% {
background: #ffd23f;
color: #17141a;
}
}
@keyframes frM4 {
0%, 100% {
background: #ffd23f;
color: #17141a;
}
}
@keyframes frM5 {
0%, 57% {
background: #ffd23f;
color: #17141a;
}
58%, 100% {
background: #ff4d1f;
color: #17141a;
}
}
@keyframes frCntA {
0%, 57% {
opacity: 1;
}
58%, 100% {
opacity: 0;
}
}
@keyframes frCntB {
0%, 57% {
opacity: 0;
}
58%, 100% {
opacity: 1;
}
}
@keyframes frGoPulse {
0%, 70% {
transform: scale(1);
}
76% {
transform: scale(1.14);
}
84%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.fr {
gap: 4px;
padding: 8px;
}
.fr__wrap {
height: 104px;
}
.fr__hl, .fr__ta {
font-size: 11px;
line-height: 16px;
padding: 4px;
}
.fr__note {
font-size: 10px;
}
.fr__find {
gap: 4px;
padding: 4px;
}
.fr__q, .fr__r {
height: 24px;
font-size: 11px;
line-height: 22px;
}
.fr__count {
height: 24px;
min-width: 36px;
font-size: 10px;
}
.fr__nav {
width: 24px;
height: 24px;
font-size: 12px;
}
.fr__go {
height: 24px;
padding: 0 8px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fr.is-demo .fr__find,
.fr.is-demo .fr__hl mark,
.fr.is-demo .fr__cnum,
.fr.is-demo .fr__go {
animation: none !important;
opacity: 1 !important;
visibility: visible !important;
transform: none !important;
}
.fr.is-demo .fr__cnum--b {
opacity: 0 !important;
}
}var fr = document.getElementById('fr');
var ta = document.getElementById('ta');
var hl = document.getElementById('hl');
var q = document.getElementById('q');
var rep = document.getElementById('rep');
var count = document.getElementById('count');
var idx = 0;
// 찾을 말을 정규식 특수문자 없이 글자 그대로 잡는다
function escRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function esc(t) { return t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
// matchAll 로 모든 자리를 한 번에 뽑고, 몇 번째인지 셀 수 있게 시작·끝 인덱스를 남긴다
function hits() {
if (!q.value) return [];
var re = new RegExp(escRe(q.value), 'g');
return Array.from(ta.value.matchAll(re)).map(function (m) {
return { s: m.index, e: m.index + m[0].length };
});
}
// 오버레이 pre 를 다시 칠한다 — 표시(mark)는 늘 오버레이 쪽이지 textarea 가 아니다
function paint() {
var list = hits();
if (idx >= list.length) idx = 0;
var out = '', at = 0;
list.forEach(function (m, i) {
out += esc(ta.value.slice(at, m.s))
+ '<mark class="fr__m' + (i === idx ? ' fr__m--cur' : '') + '">'
+ esc(ta.value.slice(m.s, m.e)) + '</mark>';
at = m.e;
});
hl.innerHTML = out + esc(ta.value.slice(at));
count.textContent = list.length ? (idx + 1) + '/' + list.length : '0';
var cur = hl.querySelector('.fr__m--cur');
if (cur) cur.scrollIntoView({ block: 'nearest', inline: 'nearest' });
ta.scrollTop = hl.scrollTop;
ta.scrollLeft = hl.scrollLeft;
}
function step(d) {
var n = hits().length;
if (!n) return;
idx = (idx + d + n) % n;
paint();
}
function openBar() {
fr.classList.add('is-find');
q.focus({ preventScroll: true });
q.select();
paint();
}
function closeBar() {
fr.classList.remove('is-find');
ta.focus({ preventScroll: true });
}
// 바꾸기는 지금 자리 하나만 — 전체 바꾸기가 아니라는 게 이 부품의 주제다
function replaceCur() {
var list = hits();
if (!list.length) return;
var m = list[idx];
ta.value = ta.value.slice(0, m.s) + rep.value + ta.value.slice(m.e);
paint();
}
fr.addEventListener('pointerdown', function () { fr.classList.remove('is-demo'); });
fr.addEventListener('focusin', function () { fr.classList.remove('is-demo'); });
fr.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'F')) { e.preventDefault(); openBar(); }
});
q.addEventListener('input', function () { idx = 0; paint(); });
q.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); step(e.shiftKey ? -1 : 1); }
else if (e.key === 'Escape') { closeBar(); }
});
rep.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); replaceCur(); }
else if (e.key === 'Escape') { closeBar(); }
});
document.getElementById('prev').addEventListener('click', function () { step(-1); });
document.getElementById('next').addEventListener('click', function () { step(1); });
document.getElementById('close').addEventListener('click', closeBar);
document.getElementById('go').addEventListener('click', replaceCur);
ta.addEventListener('input', paint);
ta.addEventListener('scroll', function () { hl.scrollTop = ta.scrollTop; hl.scrollLeft = ta.scrollLeft; });
// 격자 미리보기 동안에는 손으로 써 둔 오버레이를 그대로 둔다 — 사람이 손대는 순간부터 다시 칠한다
if (!fr.classList.contains('is-demo')) paint();// FindReplaceBar — vanilla 판(run/299/demo/06-editor-find-replace-bar/index.html) 의 React 포트.
// Ctrl+F 로 찾기 바가 코드 위에 내려오고, 맞는 자리마다 오버레이 pre 안의 mark 로 표시가 깔린다.
// 표시는 늘 오버레이 쪽이다 — textarea 의 글자는 투명하고 색은 아래 pre 가 그린다(01 과 같은 두 겹 기법).
// 찾기·세기·이동·바꾸기 로직은 vanilla 의 escRe·hits·paint·step·replaceCur 과 같은 글자다.
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FindReplaceBar.module.scss";
export type FindReplaceBarProps = {
duration?: number;
easing?: string;
color?: string;
initialCode?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef<T> 는 T | null 을 돌려준다 — ambient 타입에 맞춰 상자 타입을 따로 둔다
type Box<T> = { current: T | null };
// ambient 에 React 이벤트 제네릭이 없다 — 쓰는 필드만 로컬 타입으로 선언한다
type KeyEvt = { key: string; shiftKey: boolean; ctrlKey: boolean; metaKey: boolean; preventDefault(): void };
type TextEvt = { target: { value: string } };
const FILE_NAME = "tokens.scss";
const HINT = "Ctrl+F";
const FIND_LABEL = "찾기·바꾸기";
const QUERY_LABEL = "찾을 말";
const REPLACE_LABEL = "바꿀 말";
const PREV_LABEL = "이전 자리";
const NEXT_LABEL = "다음 자리";
const CLOSE_LABEL = "닫기";
const GO_LABEL = "바꾸기";
const TA_LABEL = "코드 입력칸";
const NOTE = "Enter 로 다음 자리로, 바꾸기는 지금 자리만 바꿉니다";
const COUNT_A = "3/6";
const COUNT_B = "5/6";
const QUERY = "4px";
const REPLACEMENT = "8px";
const CODE = [
"/* spacing scale — 4px grid */",
"$sp-1: 4px;",
"$sp-2: 8px;",
".card {",
" padding: 4px 12px;",
" gap: 4px;",
" border-radius: 4px;",
"}",
".chip { margin: 4px; }",
].join("\n");
type Hit = { s: number; e: number };
// 찾을 말을 정규식 특수문자 없이 글자 그대로 잡는다
function escRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeHtml(text: string): string {
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
// matchAll 로 모든 자리를 한 번에 뽑고, 몇 번째인지 셀 수 있게 시작·끝 인덱스를 남긴다
function hits(text: string, query: string): Hit[] {
if (!query) return [];
const re = new RegExp(escRe(query), "g");
return Array.from(text.matchAll(re)).map((m) => {
const at = m.index ?? 0; // RegExpMatchArray.index 는 선택 필드다 — strict 에서 0 으로 받친다
return { s: at, e: at + m[0].length };
});
}
// 오버레이 pre 의 HTML 을 짓는다 — 표시(mark)는 늘 오버레이 쪽이지 textarea 가 아니다.
// cur 가 -1 이면 현재 자리 없이 깔기만 한다(격자 미리보기 경로).
function paint(text: string, list: Hit[], cur: number): string {
let out = "";
let at = 0;
list.forEach((m, i) => {
const on = i === cur;
out += escapeHtml(text.slice(at, m.s)) +
'<mark class="' + styles.m + (on ? " " + styles["m--cur"] : "") + '"' + (on ? ' data-cur="1"' : "") + ">" +
escapeHtml(text.slice(m.s, m.e)) + "</mark>";
at = m.e;
});
return out + escapeHtml(text.slice(at));
}
const ICON_SEARCH = (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
);
export default function FindReplaceBar(props: FindReplaceBarProps) {
const duration = props.duration ?? 220;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#ffd23f";
const [code, setCode] = useState<string>(props.initialCode ?? CODE);
const [query, setQuery] = useState<string>(QUERY);
const [replacement, setReplacement] = useState<string>(REPLACEMENT);
const [idx, setIdx] = useState<number>(0);
const [findOpen, setFindOpen] = useState<boolean>(false);
const [preview, setPreview] = useState<boolean>(true);
const taRef = useRef<HTMLTextAreaElement>(null) as Box<HTMLTextAreaElement>;
const hlRef = useRef<HTMLPreElement>(null) as Box<HTMLPreElement>;
const qRef = useRef<HTMLInputElement>(null) as Box<HTMLInputElement>;
const list = useMemo(() => hits(code, query), [code, query]);
// vanilla 의 paint() 첫 줄과 같다 — 자리 수가 줄면 처음으로 되돌아간다
const cur = idx >= list.length ? 0 : idx;
// 격자 미리보기 동안에는 현재 자리(주황)를 찍지 않는다 — 손으로 써 둔 오버레이와 같은 모양이다
const html = useMemo(() => paint(code, list, preview ? -1 : cur), [code, list, cur, preview]);
// 현재 자리를 눈에 들이고 두 겹의 가로·세로 스크롤을 맞춘다. 미리보기 루프는 건드리지 않는다
useEffect(() => {
if (preview) return;
const hl = hlRef.current;
const ta = taRef.current;
if (!hl || !ta) return;
const el = hl.querySelector("[data-cur]"); // CSS 모듈은 클래스 이름을 바꾸므로 표식 속성으로 찾는다
if (el) el.scrollIntoView({ block: "nearest", inline: "nearest" });
ta.scrollTop = hl.scrollTop;
ta.scrollLeft = hl.scrollLeft;
}, [html, preview]);
// 바가 막 열린 프레임에서 찾을 말 칸을 잡는다 — 닫힌 동안은 visibility: hidden 이라 focus 가 먹지 않는다
useEffect(() => {
if (!findOpen) return;
const el = qRef.current;
if (!el) return;
el.focus({ preventScroll: true });
el.select();
}, [findOpen]);
const openBar = () => {
setPreview(false);
setFindOpen(true);
const el = qRef.current;
if (el) {
el.focus({ preventScroll: true }); // 이미 열려 있으면 이 자리에서 바로 잡힌다
el.select();
}
};
const closeBar = () => {
setFindOpen(false);
const el = taRef.current;
if (el) el.focus({ preventScroll: true });
};
const step = (d: number) => {
const n = list.length;
if (!n) return;
setIdx((cur + d + n) % n);
};
// 바꾸기는 지금 자리 하나만 — 전체 바꾸기가 아니라는 게 이 부품의 주제다
const replaceCur = () => {
if (!list.length) return;
const m = list[cur];
setCode(code.slice(0, m.s) + replacement + code.slice(m.e));
};
const sync = () => {
const hl = hlRef.current;
const ta = taRef.current;
if (hl && ta) {
hl.scrollTop = ta.scrollTop;
hl.scrollLeft = ta.scrollLeft;
}
};
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const cls = styles.root + (preview ? " " + styles.isDemo : "") + (findOpen ? " " + styles.isFind : "");
return (
<main className={styles.stage}>
<div
className={cls}
style={vars}
onPointerDown={() => setPreview(false)}
onFocus={() => setPreview(false)}
onKeyDown={(e: KeyEvt) => {
if ((e.ctrlKey || e.metaKey) && (e.key === "f" || e.key === "F")) {
e.preventDefault();
openBar();
}
}}
>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">{ICON_SEARCH}</span>
<span className={styles.name}>{FILE_NAME}</span>
<span className={styles.hint}>{HINT}</span>
</div>
<div className={styles.wrap}>
<div className={styles.find} role="group" aria-label={FIND_LABEL}>
<div className={styles.row}>
<span className={styles.fico} aria-hidden="true">{ICON_SEARCH}</span>
<input
className={styles.q}
ref={qRef}
type="text"
value={query}
placeholder={QUERY_LABEL}
aria-label={QUERY_LABEL}
spellCheck={false}
autoComplete="off"
onChange={(e: TextEvt) => {
setIdx(0);
setQuery(e.target.value);
}}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter") {
e.preventDefault();
step(e.shiftKey ? -1 : 1);
} else if (e.key === "Escape") {
closeBar();
}
}}
/>
<span className={styles.count} aria-live="polite">
{preview ? (
<>
<span className={styles.cnum + " " + styles["cnum--a"]}>{COUNT_A}</span>
<span className={styles.cnum + " " + styles["cnum--b"]}>{COUNT_B}</span>
</>
) : (
list.length ? (cur + 1) + "/" + list.length : "0"
)}
</span>
<button className={styles.nav} type="button" aria-label={PREV_LABEL} onClick={() => step(-1)}>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M160 48v160l-80-80Z" opacity=".2" />
<path d="M163.06 40.61a8 8 0 0 0-8.72 1.73l-80 80a8 8 0 0 0 0 11.32l80 80A8 8 0 0 0 168 208V48a8 8 0 0 0-4.94-7.39M152 188.69L91.31 128L152 67.31Z" />
</g>
</svg>
</button>
<button className={styles.nav} type="button" aria-label={NEXT_LABEL} onClick={() => step(1)}>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="m176 128l-80 80V48Z" opacity=".2" />
<path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z" />
</g>
</svg>
</button>
<button className={styles.nav} type="button" aria-label={CLOSE_LABEL} onClick={closeBar}>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z" />
</g>
</svg>
</button>
</div>
<div className={styles.row}>
<input
className={styles.r}
type="text"
value={replacement}
placeholder={REPLACE_LABEL}
aria-label={REPLACE_LABEL}
spellCheck={false}
autoComplete="off"
onChange={(e: TextEvt) => setReplacement(e.target.value)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter") {
e.preventDefault();
replaceCur();
} else if (e.key === "Escape") {
closeBar();
}
}}
/>
<button className={styles.go} type="button" onClick={replaceCur}>{GO_LABEL}</button>
</div>
</div>
<pre className={styles.hl} ref={hlRef} aria-hidden="true" dangerouslySetInnerHTML={{ __html: html }} />
<textarea
className={styles.ta}
ref={taRef}
value={code}
spellCheck={false}
autoComplete="off"
wrap="off"
aria-label={TA_LABEL}
onChange={(e: TextEvt) => {
setPreview(false);
setCode(e.target.value);
}}
onScroll={sync}
/>
</div>
<p className={styles.note}>{NOTE}</p>
</div>
</main>
);
}06 · Find and replace bar — Measured area 15.163% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Every match in one pass
// matchAll pulls every position in one pass, keeping start and end indexes so the current one can be counted
function hits() {
if (!q.value) return [];
var re = new RegExp(escRe(q.value), 'g');
return Array.from(ta.value.matchAll(re)).map(function (m) {
return { s: m.index, e: m.index + m[0].length };
});
}
Searching 4px in the token sample bands six spots with the counter at 1/6, and Enter walks it to 2/6 without touching the text. Replacing once rewrites only the current spot, so the six hits drop to five and the counter re-reads 2/5 from the fresh list rather than from a remembered number.
07Caret autocomplete popup
The list narrows to the word you are typing, and the popup opens directly under the caret, which is the part people miss most in a query box with a fixed keyword set.

<main class="stage">
<div class="ac is-demo" id="ac">
<div class="ac__bar">
<span class="ac__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><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></span>
<span class="ac__name">query.box</span>
<span class="ac__hint">12 keywords</span>
</div>
<div class="ac__field">
<span class="ac__mirror" id="mirror" aria-hidden="true"></span>
<pre class="ac__hl" id="hl" aria-hidden="true"><span class="ac__line"><span class="ac__base">document.</span><span class="ac__frag ac__frag--a">document.<span class="ac__caret"></span></span><span class="ac__frag ac__frag--b">document.<span class="ac__u">q</span><span class="ac__caret"></span></span></span></pre>
<textarea class="ac__ta" id="ta" rows="1" spellcheck="false" autocomplete="off" wrap="off" aria-label="Query input" aria-expanded="false" aria-autocomplete="list" aria-controls="ac-pop">document.q</textarea>
<div class="ac__pop" id="ac-pop" role="listbox" aria-label="Suggestion list" aria-activedescendant="ac-opt0">
<div class="ac__opt" id="ac-opt0" role="option" aria-selected="true">querySelector</div>
<div class="ac__opt" id="ac-opt1" role="option" aria-selected="false">querySelectorAll</div>
</div>
</div>
<div class="ac__keys" aria-hidden="true">
<span class="ac__klabel">Keywords</span>
<span class="ac__k">document</span>
<span class="ac__k">documentElement</span>
<span class="ac__k">querySelector</span>
<span class="ac__k">querySelectorAll</span>
<span class="ac__k">getElementById</span>
<span class="ac__k">getElementsByClassName</span>
<span class="ac__k">getElementsByTagName</span>
<span class="ac__k">getAttribute</span>
<span class="ac__k">setAttribute</span>
<span class="ac__k">addEventListener</span>
<span class="ac__k">removeEventListener</span>
<span class="ac__k">requestAnimationFrame</span>
</div>
<p class="ac__note">The list narrows as you type, arrow keys choose, Enter inserts</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 커서 자리 자동완성 — 치던 낱말(조각)로 startsWith 좁히기를 하고, 거울 요소로 잰 커서 x 좌표
// 바로 아래에 role="listbox" 팝업을 뜨게 한다. ↑↓ 로 항목을 고르고(aria-activedescendant 갱신),
// Enter 로 조각을 통째로 갈아 끼운다. 고정 위치 드롭다운이 아니라 커서를 따라가는 팝업이다.
// 팝업은 절대배치라 부모 높이 계산에서 빠진다 — 아래 키워드 칩 줄이 열린 팝업 높이만큼의
// 여유(예약 높이)를 함께 담당한다(237 실측: 확보가 없으면 320px 에서 넘친다).
// 격자 미리보기(.is-demo)는 조각 두 상태를 steps(1, end) 컷으로 갈아 끼우고 팝업을 컷 + translateY
// 팝으로 띄운 다음, 선택 밴드가 1번 항목에서 2번 항목으로 옮겨 간다. 페이드는 쓰지 않는다.
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(23, 20, 26, .16);
$dim: rgba(23, 20, 26, .72);
$fs: clamp(11px, 3vw, 13px);
$lh: 20px;
@include stage($stage-paper);
// 격자 미리보기 루프 공통 — 낱개 속성만 쓴다(단축은 delay 를 지운다), 시작 전은 0% 상태로 채운다
@mixin loop($name, $timing) {
animation-name: $name;
animation-duration: $dur-loop;
animation-timing-function: $timing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ac {
display: flex; flex-direction: column; gap: $sp-1;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: $stage-paper; box-shadow: $shadow-raised;
}
// ── 파일 이름 줄
.ac__bar { display: flex; align-items: center; gap: $sp-2; }
.ac__ico { display: grid; place-items: center; color: $color; font-size: 15px; }
.ac__name {
flex: 1 1 auto; color: $subject-ink;
font-family: $mono; font-weight: 700; font-size: 12px; line-height: 1.4;
}
.ac__hint {
flex: 0 0 auto; padding: $sp-1 $sp-2; border: 1px solid $line; border-radius: $r-pill;
color: $dim; font-family: $mono; font-weight: 700; font-size: 10px; line-height: 1.4;
}
// ── 입력줄. 테두리는 이 껍데기가 갖고 pre·textarea 는 포개 얹는다(01 과 같은 두 겹 기법)
// 트랙을 minmax(0, 1fr) 로 못박는다 — auto 트랙은 항목 내용만큼 커질 수 있다
.ac__field { position: relative; display: grid; grid-template-rows: minmax(0, 1fr); height: $lh + $sp-4 + 2px;
border: 1px solid $line; border-radius: $r-sm; background: $subject-cream; }
.ac__hl, .ac__ta { grid-area: 1 / 1; }
%layer {
box-sizing: border-box; margin: 0; padding: $sp-2;
border: 0; border-radius: $r-sm;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
letter-spacing: 0; tab-size: 2;
white-space: pre; overflow: hidden;
}
.ac__hl {
@extend %layer;
color: $subject-ink; pointer-events: none;
}
// 글자는 투명하고 캐럿만 남긴다 — 글자색은 아래 pre 가 그린다
.ac__ta {
@extend %layer;
background: transparent; color: transparent; caret-color: $subject-ink;
resize: none;
-webkit-text-fill-color: transparent;
}
.ac__ta::selection { background: rgba(47, 109, 246, .24); }
.ac__ta:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 거울 요소 — 커서 x 좌표를 재는 자로, 화면에 보이지 않게 절대배치로 뺀다
.ac__mirror {
position: absolute; top: 0; left: 0; visibility: hidden; white-space: pre;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh; letter-spacing: 0;
}
// 미리보기 전용 두 조각 — 절대배치로 같은 지점에 포개고 한쪽만 보인다
.ac__line { position: relative; display: block; }
.ac__base { color: $subject-ink; }
.ac__frag { position: absolute; left: 0; top: 0; visibility: hidden; white-space: pre; color: $subject-ink; }
// 치던 조각에 깔리는 밑줄 — 실사용 경로(.ac__word)와 미리보기(.ac__u)가 같은 옷을 입는다
.ac__word, .ac__u {
text-decoration: underline; text-decoration-color: $color;
text-decoration-thickness: 2px; text-underline-offset: 3px;
}
.ac__caret {
display: inline-block; width: 2px; height: 14px;
background: $subject-ink; vertical-align: -2px;
}
// ── 후보 팝업 — 커서 바로 아래. listbox 경로로 스크린리더가 읽는다
.ac__pop {
position: absolute; top: calc(100% + 4px); left: 0; z-index: 3;
min-width: 120px; box-sizing: border-box;
padding: $sp-1; border: 1px solid $line; border-radius: $r-control;
background: $subject-cream; box-shadow: $shadow-raised;
opacity: 0; visibility: hidden; transform: translateY(4px); pointer-events: none;
transition: opacity $duration $ease-out, transform $duration $easing, visibility $duration;
}
.ac.is-open .ac__pop { opacity: 1; visibility: visible; transform: translateY(0); pointer-events: auto; }
.ac__opt {
padding: $sp-1 $sp-2; border-radius: $r-sm;
color: $subject-ink; font-family: $mono; font-weight: 500; font-size: 12px; line-height: 1.4;
white-space: nowrap;
}
// 고른 항목 — 깊은 파랑 면 위 크림(옅은 파랑 면이면 미달이다)
.ac__opt[aria-selected="true"] { background: $subject-blue-deep; color: $subject-cream; }
// ── 키워드 칩 줄. 열린 팝업이 덮는 자리이자, 절대배치 높이의 예약분이다
.ac__keys { display: flex; flex-wrap: wrap; align-items: center; gap: $sp-1; }
.ac__klabel { color: $dim; font-weight: 700; font-size: 10px; line-height: 1.4; }
.ac__k {
padding: $sp-1 $sp-2; border: 1px solid $line; border-radius: $r-pill;
background: $stage-paper; color: $subject-ink;
font-family: $mono; font-weight: 500; font-size: 10px; line-height: 1.4;
}
.ac__note {
margin: 0; color: $dim;
font-weight: 500; font-size: 11px; line-height: 1.4;
}
// ── 격자 미리보기 루프 (2초)
// 0~5% "document." 를 치던 중(캐럿 깜빡임) → 6% 조각이 "document.q" 로 컷 교체, 8% 팝업이 컷 + 팝으로
// 열리며 후보 2개 → 42% 선택 밴드가 1번(querySelector)에서 2번(querySelectorAll)으로 컷 이동 → 유지.
// 팝업이 사라지는 장면은 찍지 않는다 — 퇴장 프레임이 poster 로 뽑히면 빈 화면이 된다.
.ac.is-demo .ac__base { color: transparent; } // 공간은 유지한 채 감춘다(display:none 은 레이아웃을 지운다)
.ac.is-demo .ac__frag--a { @include loop(acFragA, steps(1, end)); }
.ac.is-demo .ac__frag--b { @include loop(acFragB, steps(1, end)); }
.ac.is-demo .ac__pop {
animation-name: acPopVis, acPopIn;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.ac.is-demo #ac-opt0 { @include loop(acSel0, steps(1, end)); }
.ac.is-demo #ac-opt1 { @include loop(acSel1, steps(1, end)); }
// 캐럿은 루프 내내 깜빡인다 — steps 컷이라 반투명 유령이 없다
.ac.is-demo .ac__caret { @include loop(acBlink, steps(1, end)); }
@keyframes acFragA {
0%, 5% { visibility: visible; }
6%, 100% { visibility: hidden; }
}
@keyframes acFragB {
0%, 5% { visibility: hidden; }
6%, 100% { visibility: visible; }
}
@keyframes acPopVis {
0%, 7% { opacity: 0; visibility: hidden; }
8%, 100% { opacity: 1; visibility: visible; }
}
@keyframes acPopIn {
0%, 7% { transform: translateY(6px); }
18%, 100% { transform: translateY(0); }
}
@keyframes acSel0 {
0%, 38% { background: $subject-blue-deep; color: $subject-cream; }
42%, 100% { background: transparent; color: $subject-ink; }
}
@keyframes acSel1 {
0%, 38% { background: transparent; color: $subject-ink; }
42%, 100% { background: $subject-blue-deep; color: $subject-cream; }
}
// 2초에 여섯 번 깜빡임 — 8% 간격 컷
@keyframes acBlink {
0%, 7% { opacity: 1; }
8%, 15% { opacity: 0; }
16%, 23% { opacity: 1; }
24%, 31% { opacity: 0; }
32%, 39% { opacity: 1; }
40%, 47% { opacity: 0; }
48%, 55% { opacity: 1; }
56%, 63% { opacity: 0; }
64%, 71% { opacity: 1; }
72%, 79% { opacity: 0; }
80%, 87% { opacity: 1; }
88%, 95% { opacity: 0; }
96%, 100% { opacity: 1; }
}
// ── 좁은 화면: 행간과 여백을 줄여 무대 안에 들어오게 한다. 칩은 앞 4개만(get* 계열은 폭이 길어 줄이 넘는다)
@media (max-width: 340px) {
.ac { gap: $sp-1; padding: $sp-2; }
.ac__keys > :nth-child(n+6) { display: none; }
.ac__field { height: 16px + $sp-2 + 2px; }
.ac__hl, .ac__ta { font-size: 11px; line-height: 16px; padding: $sp-1; }
.ac__mirror { font-size: 11px; line-height: 16px; }
.ac__caret { height: 12px; vertical-align: -2px; }
.ac__opt { font-size: 11px; padding: $sp-1; }
.ac__k, .ac__klabel { font-size: 10px; }
.ac__name { font-size: 11px; }
.ac__note { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ac.is-demo .ac__frag--a,
.ac.is-demo .ac__frag--b,
.ac.is-demo .ac__pop,
.ac.is-demo .ac__caret,
.ac.is-demo #ac-opt0,
.ac.is-demo #ac-opt1 {
animation: none !important;
opacity: 1 !important;
visibility: visible !important;
transform: none !important;
}
// 조각은 두 번째 상태로, 캐럿은 켠 채 멈춘다
.ac.is-demo .ac__frag--a { visibility: hidden !important; }
.ac.is-demo .ac__caret { opacity: 1 !important; }
}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);
}
.ac {
display: flex;
flex-direction: column;
gap: 4px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ac__bar {
display: flex;
align-items: center;
gap: 8px;
}
.ac__ico {
display: grid;
place-items: center;
color: #2f6df6;
font-size: 15px;
}
.ac__name {
flex: 1 1 auto;
color: #17141a;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
}
.ac__hint {
flex: 0 0 auto;
padding: 4px 8px;
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 999px;
color: rgba(23, 20, 26, 0.72);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.ac__field {
position: relative;
display: grid;
grid-template-rows: minmax(0, 1fr);
height: 38px;
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 8px;
background: #fff7e6;
}
.ac__hl, .ac__ta {
grid-area: 1/1;
}
.ac__ta, .ac__hl {
box-sizing: border-box;
margin: 0;
padding: 8px;
border: 0;
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
letter-spacing: 0;
tab-size: 2;
white-space: pre;
overflow: hidden;
}
.ac__hl {
color: #17141a;
pointer-events: none;
}
.ac__ta {
background: transparent;
color: transparent;
caret-color: #17141a;
resize: none;
-webkit-text-fill-color: transparent;
}
.ac__ta::selection {
background: rgba(47, 109, 246, 0.24);
}
.ac__ta:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ac__mirror {
position: absolute;
top: 0;
left: 0;
visibility: hidden;
white-space: pre;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
letter-spacing: 0;
}
.ac__line {
position: relative;
display: block;
}
.ac__base {
color: #17141a;
}
.ac__frag {
position: absolute;
left: 0;
top: 0;
visibility: hidden;
white-space: pre;
color: #17141a;
}
.ac__word, .ac__u {
text-decoration: underline;
text-decoration-color: #2f6df6;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.ac__caret {
display: inline-block;
width: 2px;
height: 14px;
background: #17141a;
vertical-align: -2px;
}
.ac__pop {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 3;
min-width: 120px;
box-sizing: border-box;
padding: 4px;
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 12px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
visibility: hidden;
transform: translateY(4px);
pointer-events: none;
transition: opacity 220ms ease-out, transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), visibility 220ms;
}
.ac.is-open .ac__pop {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.ac__opt {
padding: 4px 8px;
border-radius: 8px;
color: #17141a;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
}
.ac__opt[aria-selected=true] {
background: #0a4fe8;
color: #fff7e6;
}
.ac__keys {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.ac__klabel {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.ac__k {
padding: 4px 8px;
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 10px;
line-height: 1.4;
}
.ac__note {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.ac.is-demo .ac__base {
color: transparent;
}
.ac.is-demo .ac__frag--a {
animation-name: acFragA;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ac.is-demo .ac__frag--b {
animation-name: acFragB;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ac.is-demo .ac__pop {
animation-name: acPopVis, acPopIn;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.ac.is-demo #ac-opt0 {
animation-name: acSel0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ac.is-demo #ac-opt1 {
animation-name: acSel1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ac.is-demo .ac__caret {
animation-name: acBlink;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes acFragA {
0%, 5% {
visibility: visible;
}
6%, 100% {
visibility: hidden;
}
}
@keyframes acFragB {
0%, 5% {
visibility: hidden;
}
6%, 100% {
visibility: visible;
}
}
@keyframes acPopVis {
0%, 7% {
opacity: 0;
visibility: hidden;
}
8%, 100% {
opacity: 1;
visibility: visible;
}
}
@keyframes acPopIn {
0%, 7% {
transform: translateY(6px);
}
18%, 100% {
transform: translateY(0);
}
}
@keyframes acSel0 {
0%, 38% {
background: #0a4fe8;
color: #fff7e6;
}
42%, 100% {
background: transparent;
color: #17141a;
}
}
@keyframes acSel1 {
0%, 38% {
background: transparent;
color: #17141a;
}
42%, 100% {
background: #0a4fe8;
color: #fff7e6;
}
}
@keyframes acBlink {
0%, 7% {
opacity: 1;
}
8%, 15% {
opacity: 0;
}
16%, 23% {
opacity: 1;
}
24%, 31% {
opacity: 0;
}
32%, 39% {
opacity: 1;
}
40%, 47% {
opacity: 0;
}
48%, 55% {
opacity: 1;
}
56%, 63% {
opacity: 0;
}
64%, 71% {
opacity: 1;
}
72%, 79% {
opacity: 0;
}
80%, 87% {
opacity: 1;
}
88%, 95% {
opacity: 0;
}
96%, 100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.ac {
gap: 4px;
padding: 8px;
}
.ac__keys > :nth-child(n+6) {
display: none;
}
.ac__field {
height: 26px;
}
.ac__hl, .ac__ta {
font-size: 11px;
line-height: 16px;
padding: 4px;
}
.ac__mirror {
font-size: 11px;
line-height: 16px;
}
.ac__caret {
height: 12px;
vertical-align: -2px;
}
.ac__opt {
font-size: 11px;
padding: 4px;
}
.ac__k, .ac__klabel {
font-size: 10px;
}
.ac__name {
font-size: 11px;
}
.ac__note {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ac.is-demo .ac__frag--a,
.ac.is-demo .ac__frag--b,
.ac.is-demo .ac__pop,
.ac.is-demo .ac__caret,
.ac.is-demo #ac-opt0,
.ac.is-demo #ac-opt1 {
animation: none !important;
opacity: 1 !important;
visibility: visible !important;
transform: none !important;
}
.ac.is-demo .ac__frag--a {
visibility: hidden !important;
}
.ac.is-demo .ac__caret {
opacity: 1 !important;
}
}var ac = document.getElementById('ac');
var ta = document.getElementById('ta');
var hl = document.getElementById('hl');
var pop = document.getElementById('ac-pop');
var mirror = document.getElementById('mirror');
var sel = 0;
// 정해진 Keywords 목록 — 치던 낱말(조각)로 startsWith 좁히기를 한다
var KEYS = ['document', 'documentElement', 'querySelector', 'querySelectorAll',
'getElementById', 'getElementsByClassName', 'getElementsByTagName',
'getAttribute', 'setAttribute', 'addEventListener', 'removeEventListener',
'requestAnimationFrame'];
function esc(t) { return t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
// 커서 바로 앞의 낱말 조각 — 마지막 비단어 문자(점·공백) 뒤부터 커서까지가 조각이다
function wordAt(v, pos) {
var i = pos;
while (i > 0 && /[\w$]/.test(v[i - 1])) i--;
return { start: i, text: v.slice(i, pos) };
}
function candidates(word) {
return KEYS.filter(function (k) { return k.toLowerCase().startsWith(word.toLowerCase()); });
}
// 거울 요소 — 입력칸과 같은 서체로 커서 앞 글자를 재서 커서 x 좌표를 잰다
function caretX(pos) {
mirror.textContent = ta.value.slice(0, pos);
var pad = parseFloat(getComputedStyle(ta).paddingLeft) || 0;
return pad + mirror.offsetWidth - ta.scrollLeft;
}
// 팝업 왼쪽을 커서 x 에 맞춘다 — 오른쪽 끝을 넘으면 안으로 접는다
function place(pos) {
var x = caretX(pos);
var maxX = ta.clientWidth - pop.offsetWidth - 4;
pop.style.left = Math.max(0, Math.min(x, Math.max(0, maxX))) + 'px';
}
function update() {
var v = ta.value, pos = ta.selectionStart;
var w = wordAt(v, pos);
var list = w.text ? candidates(w.text) : [];
if (sel >= list.length) sel = 0;
// 오버레이를 다시 그린다 — 치던 조각에 밑줄이 깔린다
hl.innerHTML = esc(v.slice(0, w.start))
+ (w.text ? '<span class="ac__word">' + esc(w.text) + '</span>' : '')
+ esc(v.slice(w.start + w.text.length));
if (list.length) {
pop.innerHTML = list.map(function (k, i) {
return '<div class="ac__opt" id="ac-opt' + i + '" role="option" aria-selected="'
+ (i === sel) + '">' + k + '</div>';
}).join('');
pop.setAttribute('aria-activedescendant', 'ac-opt' + sel);
ac.classList.add('is-open');
} else {
ac.classList.remove('is-open');
pop.removeAttribute('aria-activedescendant');
}
ta.setAttribute('aria-expanded', list.length ? 'true' : 'false');
place(pos);
}
// Enter — 치던 조각을 통째로 갈아 끼운다 (조각만 바뀌고 앞뒤는 그대로)
function insert() {
var v = ta.value, pos = ta.selectionStart;
var w = wordAt(v, pos);
var list = candidates(w.text);
var k = list[sel] || list[0];
if (!k) return;
ta.value = v.slice(0, w.start) + k + v.slice(w.start + w.text.length);
var next = w.start + k.length;
ta.setSelectionRange(next, next);
update();
// update() 는 커서 앞 낱말로 후보를 다시 찾는다 — 방금 넣은 키워드가 그대로 접두어라 팝업이 도로
// 열린다. 골라 넣은 직후에는 닫힌 채로 둔다 (실측 2026-09-13: Enter 뒤에도 목록이 남아 있었다)
ac.classList.remove('is-open');
ta.setAttribute('aria-expanded', 'false');
}
ac.addEventListener('pointerdown', function () { ac.classList.remove('is-demo'); });
ac.addEventListener('focusin', function () { ac.classList.remove('is-demo'); });
ta.addEventListener('input', function () { sel = 0; update(); });
ta.addEventListener('keyup', function (e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') update();
});
ta.addEventListener('click', update);
ta.addEventListener('keydown', function (e) {
var open = ac.classList.contains('is-open');
if (open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
var n = pop.children.length;
sel = (sel + (e.key === 'ArrowDown' ? 1 : -1) + n) % n;
update();
} else if (open && e.key === 'Enter') {
e.preventDefault();
insert();
} else if (e.key === 'Escape') {
ac.classList.remove('is-open');
ta.setAttribute('aria-expanded', 'false');
}
});
// 격자 미리보기 동안에는 손으로 써 둔 후보를 그대로 두고 좌표만 잡는다
if (ac.classList.contains('is-demo')) place(ta.value.length);
else update();// AutocompletePopup — vanilla 판(run/299/demo/07-editor-autocomplete-popup/index.html) 의 React 포트.
// 치던 낱말(조각)로 startsWith 좁히기를 하고, 거울 요소로 잰 커서 x 좌표 바로 아래에
// role="listbox" 팝업을 띄운다. 화살표로 고르고(aria-activedescendant 갱신) Enter 로 조각을 갈아 끼운다.
// wordAt·candidates·caretX·place·insert 는 vanilla 와 같은 글자다 (값을 다시 타이핑하지 않는다).
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AutocompletePopup.module.scss";
export type AutocompletePopupProps = {
duration?: number;
easing?: string;
color?: string;
initialValue?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef<T> 는 T | null 을 돌려준다 — ambient 타입에 맞춰 상자 타입을 따로 둔다
type Box<T> = { current: T | null };
// ambient 에 React 이벤트 제네릭이 없다 — 쓰는 필드만 로컬 타입으로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type TextEvt = { target: { value: string; selectionStart: number | null } };
const FILE_NAME = "query.box";
const HINT = "키워드 12개";
const TA_LABEL = "쿼리 입력칸";
const POP_LABEL = "후보 목록";
const KEYS_LABEL = "키워드";
const NOTE = "치던 낱말로 좁혀지고 화살표로 골라 Enter 로 넣습니다";
const VALUE = "document.q";
const DEMO_BASE = "document.";
const DEMO_FRAG = "q";
// 정해진 키워드 목록 — 치던 낱말(조각)로 startsWith 좁히기를 한다
const KEYS = ["document", "documentElement", "querySelector", "querySelectorAll",
"getElementById", "getElementsByClassName", "getElementsByTagName",
"getAttribute", "setAttribute", "addEventListener", "removeEventListener",
"requestAnimationFrame"];
type Word = { start: number; text: string };
function escapeHtml(text: string): string {
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
// 커서 바로 앞의 낱말 조각 — 마지막 비단어 문자(점·공백) 뒤부터 커서까지가 조각이다
function wordAt(v: string, pos: number): Word {
let i = pos;
while (i > 0 && /[\w$]/.test(v[i - 1])) i--;
return { start: i, text: v.slice(i, pos) };
}
function candidates(word: string): string[] {
return KEYS.filter((k) => k.toLowerCase().startsWith(word.toLowerCase()));
}
// 오버레이를 다시 그린다 — 치던 조각에 밑줄이 깔린다
function paint(v: string, w: Word): string {
return escapeHtml(v.slice(0, w.start))
+ (w.text ? '<span class="' + styles.word + '">' + escapeHtml(w.text) + "</span>" : "")
+ escapeHtml(v.slice(w.start + w.text.length));
}
export default function AutocompletePopup(props: AutocompletePopupProps) {
const duration = props.duration ?? 220;
const easing = props.easing ?? "cubic-bezier(.2, 1.4, .4, 1)";
const color = props.color ?? "#2f6df6";
const start = props.initialValue ?? VALUE;
const [value, setValue] = useState<string>(start);
const [pos, setPos] = useState<number>(start.length);
const [sel, setSel] = useState<number>(0);
const [escaped, setEscaped] = useState<boolean>(false);
const [preview, setPreview] = useState<boolean>(true);
const [left, setLeft] = useState<number>(0);
const taRef = useRef<HTMLTextAreaElement>(null) as Box<HTMLTextAreaElement>;
const mirrorRef = useRef<HTMLSpanElement>(null) as Box<HTMLSpanElement>;
const popRef = useRef<HTMLDivElement>(null) as Box<HTMLDivElement>;
const pendingRef = useRef<number>(-1) as Box<number>; // Enter 삽입 뒤 옮겨 둘 커서 자리
const word = useMemo(() => wordAt(value, pos), [value, pos]);
const list = useMemo(() => (word.text ? candidates(word.text) : []), [word]);
// vanilla 의 update() 첫 줄과 같다 — 후보가 줄면 처음으로 되돌아간다
const cur = sel >= list.length ? 0 : sel;
const open = !escaped && list.length > 0;
const html = useMemo(() => paint(value, word), [value, word]);
// 격자 미리보기 동안에는 손으로 써 둔 후보를 그대로 두고 좌표만 잡는다
const caretAt = preview ? value.length : pos;
// 거울 요소 — 입력칸과 같은 서체로 커서 앞 글자를 재서 커서 x 좌표를 잰다.
// 팝업 왼쪽을 그 x 에 맞추고, 오른쪽 끝을 넘으면 안으로 접는다
useEffect(() => {
const ta = taRef.current;
const mirror = mirrorRef.current;
const pop = popRef.current;
if (!ta || !mirror || !pop) return;
const pad = parseFloat(getComputedStyle(ta).paddingLeft) || 0;
const x = pad + mirror.offsetWidth - ta.scrollLeft;
const maxX = ta.clientWidth - pop.offsetWidth - 4;
setLeft(Math.max(0, Math.min(x, Math.max(0, maxX))));
}, [value, caretAt, preview, open]);
// 삽입 직후에만 커서를 옮긴다 — 사람이 잡아 둔 자리를 매 렌더마다 뺏지 않으려는 가드다
useEffect(() => {
const ta = taRef.current;
const want = pendingRef.current;
if (!ta || want === null || want < 0) return;
pendingRef.current = -1;
ta.setSelectionRange(want, want);
}, [value]);
// 커서만 움직인 경로(화살표·클릭) — 조각을 다시 재고 팝업을 되살린다
const syncPos = () => {
const ta = taRef.current;
if (!ta) return;
setPos(ta.selectionStart ?? 0);
setEscaped(false);
};
// Enter — 치던 조각을 통째로 갈아 끼운다 (조각만 바뀌고 앞뒤는 그대로)
const insert = () => {
const w = wordAt(value, pos);
const cands = candidates(w.text);
const k = cands[cur] || cands[0];
if (!k) return;
const next = w.start + k.length;
pendingRef.current = next;
setValue(value.slice(0, w.start) + k + value.slice(w.start + w.text.length));
setPos(next);
setEscaped(false);
};
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const cls = styles.root + (preview ? " " + styles.isDemo : "") + (!preview && open ? " " + styles.isOpen : "");
return (
<main className={styles.stage}>
<div
className={cls}
style={vars}
onPointerDown={() => setPreview(false)}
onFocus={() => setPreview(false)}
>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<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>
</span>
<span className={styles.name}>{FILE_NAME}</span>
<span className={styles.hint}>{HINT}</span>
</div>
<div className={styles.field}>
<span className={styles.mirror} ref={mirrorRef} aria-hidden="true">{value.slice(0, caretAt)}</span>
{preview ? (
<pre className={styles.hl} aria-hidden="true">
<span className={styles.line}>
<span className={styles.base}>{DEMO_BASE}</span>
<span className={styles.frag + " " + styles["frag--a"]}>
{DEMO_BASE}<span className={styles.caret} />
</span>
<span className={styles.frag + " " + styles["frag--b"]}>
{DEMO_BASE}<span className={styles.u}>{DEMO_FRAG}</span><span className={styles.caret} />
</span>
</span>
</pre>
) : (
<pre className={styles.hl} aria-hidden="true" dangerouslySetInnerHTML={{ __html: html }} />
)}
<textarea
className={styles.ta}
ref={taRef}
value={value}
rows={1}
spellCheck={false}
autoComplete="off"
wrap="off"
aria-label={TA_LABEL}
aria-expanded={!preview && open ? "true" : "false"}
aria-autocomplete="list"
aria-controls="ac-pop"
onChange={(e: TextEvt) => {
setPreview(false);
setSel(0);
setEscaped(false);
setValue(e.target.value);
setPos(e.target.selectionStart ?? e.target.value.length);
}}
onClick={syncPos}
onKeyUp={(e: KeyEvt) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "Home" || e.key === "End") syncPos();
}}
onKeyDown={(e: KeyEvt) => {
if (open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
e.preventDefault();
const n = list.length;
setSel((cur + (e.key === "ArrowDown" ? 1 : -1) + n) % n);
} else if (open && e.key === "Enter") {
e.preventDefault();
insert();
} else if (e.key === "Escape") {
setEscaped(true);
}
}}
/>
<div
className={styles.pop}
id="ac-pop"
ref={popRef}
role="listbox"
aria-label={POP_LABEL}
aria-activedescendant={preview ? "ac-opt0" : (open ? "ac-opt" + cur : undefined)}
style={{ left: left + "px" }}
>
{preview ? (
<>
<div className={styles.opt + " " + styles["opt--a"]} id="ac-opt0" role="option" aria-selected="true">{KEYS[2]}</div>
<div className={styles.opt + " " + styles["opt--b"]} id="ac-opt1" role="option" aria-selected="false">{KEYS[3]}</div>
</>
) : (
list.map((k, i) => (
<div key={k} className={styles.opt} id={"ac-opt" + i} role="option" aria-selected={i === cur}>{k}</div>
))
)}
</div>
</div>
<div className={styles.keys} aria-hidden="true">
<span className={styles.klabel}>{KEYS_LABEL}</span>
{KEYS.map((k) => (<span className={styles.k} key={k}>{k}</span>))}
</div>
<p className={styles.note}>{NOTE}</p>
</div>
</main>
);
}07 · Caret autocomplete popup — Measured area 8.792% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Caret x measured by a mirror element
// Mirror element — measure the text before the caret in the same font to get the caret's x coordinate
function caretX(pos) {
mirror.textContent = ta.value.slice(0, pos);
var pad = parseFloat(getComputedStyle(ta).paddingLeft) || 0;
return pad + mirror.offsetWidth - ta.scrollLeft;
}
// Align the popup's left edge with the caret x — fold it back inside if it runs past the right edge
function place(pos) {
var x = caretX(pos);
var maxX = ta.clientWidth - pop.offsetWidth - 4;
pop.style.left = Math.max(0, Math.min(x, Math.max(0, maxX))) + 'px';
}
A hidden span in the same font holds everything before the caret, and the popup borrows its width, so after document.quer the popup stood at x 155 instead of the input's left edge at 54. The candidate list narrowed from 12 keywords to 2, one press of the down arrow moved aria-activedescendant from ac-opt0 to ac-opt1, and Enter swapped the half-typed fragment for document.querySelectorAll with the caret landing at 25.
08Squiggly error underline
An unclosed bracket or a malformed hex color gets a red squiggly underline and a tooltip that says why, which turns a paste box into a first line of defense.

<main class="stage">
<div class="lint is-demo" id="lint">
<div class="lint__bar">
<span class="lint__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2"/><path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></span>
<span class="lint__name">style.scss</span>
</div>
<div class="lint__wrap" id="wrap">
<span class="lint__scan" aria-hidden="true"></span>
<pre class="lint__hl" id="hl" aria-hidden="true"></pre>
<div class="lint__tip" id="tip0" aria-hidden="true"></div>
<div class="lint__tip" id="tip1" aria-hidden="true"></div>
<textarea class="lint__ta" id="ta" wrap="off" spellcheck="false" autocomplete="off" aria-label="Code input" aria-describedby="lintStatus">/* badge colors */
.badge {
color: #ff41;
}
.card {
color: #ff412;
</textarea>
</div>
<p class="lint__status" id="lintStatus" role="status">
<span class="lint__chip" id="chip" data-n="2"><span class="lint__count" id="count">2 errors</span><span class="lint__countd" aria-hidden="true"><b>0 errors</b><b>1 error</b><b>2 errors</b></span></span>
<span class="lint__hint">Hover a squiggle to see why</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 오류 물결 밑줄과 툴팁 — 글자를 투명하게 만든 textarea 를 같은 자리·같은 서체의 pre 위에 얹는
// 기법(01 실시간 구문 강조와 같은 뼈대)에 간이 린트를 얹는다. 린트 규칙은 둘: 괄호 짝(스택)과
// 색 코드 자릿수(3·4·6·8 자리의 16진수만 유효 — 4자리 #ff41 은 알파 1바이트를 포함하는 유효한
// 표기고 5자리 #ff412 가 오류다).
// 오류 조각에는 text-decoration-style: wavy 물결을 깔고 text-decoration-skip-ink: none 으로
// 내려꽂이는 글자에서도 물결이 끊기지 않게 한다. wavy 밑줄 계열 지원률 — caniuse text-decoration
// 페이지 실측 2026-09-13: 전역 96.65%(부분지원 표기, wavy·style 값은 주요 브라우저 전부 사용 가능).
// 툴팁은 절대배치다 — 절대배치는 부모 높이에서 빠지지만 scrollHeight 에는 잡혀 320px 에서 BD9 가
// 깨지므로 오류 줄 "위"쪽에 띄워 래핑 상자 안에 두고, 가로는 JS 가 오른쪽 끝으로 묶는다.
// 접근성 경로는 툴팁(마우스·키보드 커서용)이 아니라 상태 줄이다 — textarea 의 aria-describedby 가
// 상태 줄을 가리켜 포커스할 때 오류 수를 읽어 준다.
// 격자 미리보기(.is-demo)는 스캔 줄이 내려가며 지나친 줄의 물결을 steps(1, end) 컷으로 켜고
// 툴팁 두 개가 차례로 뜬다. 정적 규칙을 먼저 쓰고 그 위에 루프를 덧씌웠다(254-06 함정 대비).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-yellow; // 스캔 줄·아이콘·캐럿 — 어두운 패널 위
$err: $error; // 물결 색 — 코드 면 위 굵은 장식선 기준으로 쓴다
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$dim: #beb7ad;
$fs: clamp(11px, 3vw, 13px);
$lh: 20px;
@include stage($stage-orange);
// 에디터 패널은 어두운 면으로 깐다 — 오렌지 무대 위에 크림 작은 글자를 직접 얹으면 3.11:1 로
// AA 에 미달한다(blog-design 글자 대비 표). 글자는 전부 이 패널 위에 있다.
.lint {
--scan-y: #{6 * $lh};
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid rgba(255, 255, 255, .16); border-radius: $r-card;
background: $stage-ink; box-shadow: $shadow-raised;
}
// ── 파일 이름 줄
.lint__bar { display: flex; align-items: center; gap: $sp-2; }
.lint__ico { display: grid; place-items: center; color: $color; font-size: 15px; }
.lint__name {
flex: 1 1 auto; color: $subject-cream;
font-family: $mono; font-weight: 700; font-size: 12px; line-height: 1.4;
}
// ── 두 겹이 겹치는 자리. 한 격자 칸에 pre 와 textarea 를 포갠다
.lint__wrap { position: relative; display: grid; height: $lh * 7 + $sp-4; }
.lint__wrap > pre, .lint__wrap > textarea { grid-area: 1 / 1; }
%layer {
box-sizing: border-box; margin: 0; padding: $sp-2;
border: 0; border-radius: $r-sm;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
letter-spacing: 0; tab-size: 2;
white-space: pre; overflow: auto; overscroll-behavior: contain;
}
.lint__hl {
@extend %layer;
position: relative; z-index: 1;
background: rgba(0, 0, 0, .28); color: $subject-cream;
pointer-events: none;
}
.lint__ln { display: block; }
.lint__ln--com { color: $dim; }
.lint__err {
// 물결 밑줄 — 글자색은 그대로 두고 장식만 덧입힌다
text-decoration-line: underline;
text-decoration-style: wavy;
text-decoration-color: $err;
text-decoration-thickness: 2px;
text-decoration-skip-ink: none;
}
// 글자는 투명하고 커서만 남긴다 — 색과 물결은 아래 pre 가 그린다
.lint__ta {
@extend %layer;
position: relative; z-index: 2;
background: transparent; color: transparent; caret-color: $color;
resize: none;
-webkit-text-fill-color: transparent;
}
.lint__ta::selection { background: rgba(255, 210, 63, .32); }
.lint__ta:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 한 글자 폭 재는 실험칸 — 렌더 트리에 있어야 폭이 나오므로 visibility 로 감춘다
.lint__probe { position: absolute; visibility: hidden; white-space: pre; }
// 스캔 줄 — 미리보기에서만 쓴다(정적 경로 opacity 0). 코드 줄 뒤에 깔리게 z-index 를 낮춘다
.lint__scan {
position: absolute; z-index: 0; left: $sp-2; right: $sp-2; top: $sp-2; height: $lh;
border-radius: $r-xs; background: rgba(255, 210, 63, .14);
opacity: 0; pointer-events: none;
}
// 툴팁 — 절대배치. 오류 줄 위쪽에 띄워 래핑 상자 안에 머문다(스크롤 넘침 방지)
.lint__tip {
position: absolute; z-index: 3; max-width: 240px;
box-sizing: border-box; padding: $sp-1 $sp-2;
border: 1px solid $err; border-radius: $r-control;
background: $error-soft; color: $subject-ink;
font-weight: 500; font-size: 11px; line-height: 1.4;
opacity: 0; pointer-events: none;
}
.lint__tip b { display: block; font-weight: 700; }
.lint__tip.is-open { opacity: 1; }
// ── 상태 줄. aria-describedby 가 여기를 가리킨다
.lint__status { display: flex; align-items: center; gap: $sp-2; margin: 0; }
.lint__chip {
position: relative; box-sizing: border-box; padding: $sp-1 $sp-2; border-radius: $r-pill;
background: $err; color: $subject-cream;
font-family: $mono; font-weight: 700; font-size: 11px; line-height: 1.4;
}
.lint__chip[data-n="0"] { background: rgba(255, 247, 230, .12); }
.lint__hint { flex: 1 1 auto; color: $dim; font-weight: 500; font-size: 11px; line-height: 1.4; }
// 미리보기 전용 칩 세 개 — 실제 칩 글자와 같은 자리에 포개고 opacity 컷으로 갈아 낸다.
// 정적 경로에서는 display:none 이라 실제 편집을 가리지 않는다
.lint__countd { position: absolute; inset: 0; display: none; }
.lint__countd b {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-weight: 700;
}
// ── 격자 미리보기 루프(2초). 스캔 줄이 3~62% 에 내려가고, 지나친 오류의 물결이
// 48%·62% 에 컷으로 켜지며, 툴팁이 69%·90% 부터 차례로 뜬다.
// steps(1, end) 는 구간 끝에서만 값을 바꾼다 — 컷 지점을 별도 키프레임으로 둔다.
.lint.is-demo .lint__scan {
animation-name: lintScanFade, lintScanMove;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $ease-out;
animation-iteration-count: infinite, infinite;
}
.lint.is-demo .lint__err {
animation-name: lintErrOn;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.lint.is-demo .lint__err[data-e="0"] { animation-delay: .88s; }
.lint.is-demo .lint__err[data-e="1"] { animation-delay: 1.16s; }
.lint.is-demo .lint__count { visibility: hidden; }
.lint.is-demo .lint__countd { display: block; }
.lint.is-demo .lint__countd b {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lint.is-demo .lint__countd b:nth-child(1) { animation-name: lintChip0; }
.lint.is-demo .lint__countd b:nth-child(2) { animation-name: lintChip1; }
.lint.is-demo .lint__countd b:nth-child(3) { animation-name: lintChip2; }
.lint.is-demo .lint__tip {
animation-name: lintTipFadeA, lintTipRise;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.lint.is-demo #tip0 { animation-delay: 1.12s, 1.12s; }
.lint.is-demo #tip1 { animation-name: lintTipFadeB, lintTipRise; animation-delay: 1.5s, 1.5s; }
@keyframes lintScanFade {
0%, 2% { opacity: 0; }
3%, 64% { opacity: 1; }
65%, 100% { opacity: 0; }
}
// 이동 거리는 변수로 둔다 — 좁은 화면에서 줄 높이가 줄면 같이 줄인다
@keyframes lintScanMove {
0%, 3% { transform: translateY(0); }
62%, 100% { transform: translateY(var(--scan-y)); }
}
@keyframes lintErrOn {
0% { text-decoration-color: transparent; }
4%, 100% { text-decoration-color: $err; }
}
@keyframes lintChip0 {
0%, 47% { opacity: 1; }
48%, 100% { opacity: 0; }
}
@keyframes lintChip1 {
0%, 47% { opacity: 0; }
48%, 61% { opacity: 1; }
62%, 100% { opacity: 0; }
}
@keyframes lintChip2 {
0%, 61% { opacity: 0; }
62%, 100% { opacity: 1; }
}
// 툴팁 A(괄호)는 33% 키프레임에서 다시 꺼진다 — 지연 1.12s 를 더하면 벽시계 1.78s,
// 툴팁 B(색 코드)가 1.80s 부터 떠 두 개가 겹치지 않는다
@keyframes lintTipFadeA {
0%, 14% { opacity: 0; }
15%, 32% { opacity: 1; }
33%, 100% { opacity: 0; }
}
@keyframes lintTipFadeB {
0%, 14% { opacity: 0; }
15%, 100% { opacity: 1; }
}
@keyframes lintTipRise {
0%, 15% { transform: translateY(8px); }
28%, 100% { transform: translateY(0); }
}
// ── 좁은 화면: 행간과 여백을 줄여 무대 안에 들어온다 (2·3px 을 4px 로 올리지 않는다).
// 코드의 일곱째 줄은 빈 줄이라 여섯 줄만 보여도 내용이 사라지지 않는다 — 상자를 한 줄 줄여
// 세로 넘침(174px 한도)을 막는다. 스캔 이동거리도 여섯 줄에 맞춘다
@media (max-width: 340px) {
.lint { --scan-y: 80px; gap: $sp-1; padding: $sp-2; }
.lint__wrap { height: 16px * 6 + $sp-2; }
.lint__hl, .lint__ta { font-size: 11px; line-height: 16px; padding: $sp-1; }
.lint__scan { left: $sp-1; right: $sp-1; top: $sp-1; height: 16px; }
.lint__name { font-size: 11px; }
.lint__chip, .lint__hint { font-size: 10px; }
.lint__tip { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// 애니메이션을 끊면 정적 규칙이 그대로 드러난다 — 물결은 켜져 있고 스캔·툴팁은 꺼져 있다
.lint.is-demo .lint__scan,
.lint.is-demo .lint__err,
.lint.is-demo .lint__tip,
.lint.is-demo .lint__countd b {
animation: none !important;
}
.lint.is-demo .lint__scan,
.lint.is-demo .lint__tip { opacity: 0 !important; }
.lint.is-demo .lint__err { text-decoration-color: $err !important; }
.lint.is-demo .lint__count { visibility: visible !important; }
.lint.is-demo .lint__countd { display: none !important; }
}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);
}
.lint {
--scan-y: 120px;
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.lint__bar {
display: flex;
align-items: center;
gap: 8px;
}
.lint__ico {
display: grid;
place-items: center;
color: #ffd23f;
font-size: 15px;
}
.lint__name {
flex: 1 1 auto;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
}
.lint__wrap {
position: relative;
display: grid;
height: 156px;
}
.lint__wrap > pre, .lint__wrap > textarea {
grid-area: 1/1;
}
.lint__ta, .lint__hl {
box-sizing: border-box;
margin: 0;
padding: 8px;
border: 0;
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
letter-spacing: 0;
tab-size: 2;
white-space: pre;
overflow: auto;
overscroll-behavior: contain;
}
.lint__hl {
position: relative;
z-index: 1;
background: rgba(0, 0, 0, 0.28);
color: #fff7e6;
pointer-events: none;
}
.lint__ln {
display: block;
}
.lint__ln--com {
color: #beb7ad;
}
.lint__err {
text-decoration-line: underline;
text-decoration-style: wavy;
text-decoration-color: #c2361a;
text-decoration-thickness: 2px;
text-decoration-skip-ink: none;
}
.lint__ta {
position: relative;
z-index: 2;
background: transparent;
color: transparent;
caret-color: #ffd23f;
resize: none;
-webkit-text-fill-color: transparent;
}
.lint__ta::selection {
background: rgba(255, 210, 63, 0.32);
}
.lint__ta:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.lint__probe {
position: absolute;
visibility: hidden;
white-space: pre;
}
.lint__scan {
position: absolute;
z-index: 0;
left: 8px;
right: 8px;
top: 8px;
height: 20px;
border-radius: 4px;
background: rgba(255, 210, 63, 0.14);
opacity: 0;
pointer-events: none;
}
.lint__tip {
position: absolute;
z-index: 3;
max-width: 240px;
box-sizing: border-box;
padding: 4px 8px;
border: 1px solid #c2361a;
border-radius: 12px;
background: #ffe0d8;
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
opacity: 0;
pointer-events: none;
}
.lint__tip b {
display: block;
font-weight: 700;
}
.lint__tip.is-open {
opacity: 1;
}
.lint__status {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.lint__chip {
position: relative;
box-sizing: border-box;
padding: 4px 8px;
border-radius: 999px;
background: #c2361a;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
}
.lint__chip[data-n="0"] {
background: rgba(255, 247, 230, 0.12);
}
.lint__hint {
flex: 1 1 auto;
color: #beb7ad;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.lint__countd {
position: absolute;
inset: 0;
display: none;
}
.lint__countd b {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.lint.is-demo .lint__scan {
animation-name: lintScanFade, lintScanMove;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), ease-out;
animation-iteration-count: infinite, infinite;
}
.lint.is-demo .lint__err {
animation-name: lintErrOn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.lint.is-demo .lint__err[data-e="0"] {
animation-delay: 0.88s;
}
.lint.is-demo .lint__err[data-e="1"] {
animation-delay: 1.16s;
}
.lint.is-demo .lint__count {
visibility: hidden;
}
.lint.is-demo .lint__countd {
display: block;
}
.lint.is-demo .lint__countd b {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lint.is-demo .lint__countd b:nth-child(1) {
animation-name: lintChip0;
}
.lint.is-demo .lint__countd b:nth-child(2) {
animation-name: lintChip1;
}
.lint.is-demo .lint__countd b:nth-child(3) {
animation-name: lintChip2;
}
.lint.is-demo .lint__tip {
animation-name: lintTipFadeA, lintTipRise;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.lint.is-demo #tip0 {
animation-delay: 1.12s, 1.12s;
}
.lint.is-demo #tip1 {
animation-name: lintTipFadeB, lintTipRise;
animation-delay: 1.5s, 1.5s;
}
@keyframes lintScanFade {
0%, 2% {
opacity: 0;
}
3%, 64% {
opacity: 1;
}
65%, 100% {
opacity: 0;
}
}
@keyframes lintScanMove {
0%, 3% {
transform: translateY(0);
}
62%, 100% {
transform: translateY(var(--scan-y));
}
}
@keyframes lintErrOn {
0% {
text-decoration-color: transparent;
}
4%, 100% {
text-decoration-color: #c2361a;
}
}
@keyframes lintChip0 {
0%, 47% {
opacity: 1;
}
48%, 100% {
opacity: 0;
}
}
@keyframes lintChip1 {
0%, 47% {
opacity: 0;
}
48%, 61% {
opacity: 1;
}
62%, 100% {
opacity: 0;
}
}
@keyframes lintChip2 {
0%, 61% {
opacity: 0;
}
62%, 100% {
opacity: 1;
}
}
@keyframes lintTipFadeA {
0%, 14% {
opacity: 0;
}
15%, 32% {
opacity: 1;
}
33%, 100% {
opacity: 0;
}
}
@keyframes lintTipFadeB {
0%, 14% {
opacity: 0;
}
15%, 100% {
opacity: 1;
}
}
@keyframes lintTipRise {
0%, 15% {
transform: translateY(8px);
}
28%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.lint {
--scan-y: 80px;
gap: 4px;
padding: 8px;
}
.lint__wrap {
height: 104px;
}
.lint__hl, .lint__ta {
font-size: 11px;
line-height: 16px;
padding: 4px;
}
.lint__scan {
left: 4px;
right: 4px;
top: 4px;
height: 16px;
}
.lint__name {
font-size: 11px;
}
.lint__chip, .lint__hint {
font-size: 10px;
}
.lint__tip {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.lint.is-demo .lint__scan,
.lint.is-demo .lint__err,
.lint.is-demo .lint__tip,
.lint.is-demo .lint__countd b {
animation: none !important;
}
.lint.is-demo .lint__scan,
.lint.is-demo .lint__tip {
opacity: 0 !important;
}
.lint.is-demo .lint__err {
text-decoration-color: #c2361a !important;
}
.lint.is-demo .lint__count {
visibility: visible !important;
}
.lint.is-demo .lint__countd {
display: none !important;
}
}var lintEl = document.getElementById('lint');
var ta = document.getElementById('ta');
var hl = document.getElementById('hl');
var wrap = document.getElementById('wrap');
var chip = document.getElementById('chip');
var count = document.getElementById('count');
var TIP = [document.getElementById('tip0'), document.getElementById('tip1')];
var MSG_BRACKET = ['Unclosed bracket', 'This opening bracket has no closing pair'];
var MSG_HEX = ['Wrong digit count in a color code', 'Only hex digits in 3, 4, 6 or 8 places are valid'];
var COUNT_TPL = '{n} errors';
var HEX_DIGITS = [3, 4, 6, 8]; // 유효한 색 코드 자릿수. 4자리(#ff41 처럼 알파 1바이트)도 유효하고 5·7자리가 아니다
var lineStarts = [], errsCache = [];
// 간이 린트 — 두 규칙만. ① 색 코드: # 뒤가 16진수이고 자릿수가 3·4·6·8 중 하나여야 한다
// (# 뒤에 16진수가 아닌 글자가 붙어도 묶어서 오류로 잡는다). ② 괄호 짝: 스택으로 짝을 짓고
// 끝까지 안 닫힌 여는 괄호를 오류로 남긴다. 결과는 글자 인덱스 오름차순.
function lint(text) {
var errs = [], m;
var hexRe = /#[0-9a-zA-Z]+/g;
while ((m = hexRe.exec(text)) !== null) {
var body = m[0].slice(1);
var ok = /^[0-9a-fA-F]+$/.test(body) && HEX_DIGITS.indexOf(body.length) >= 0;
if (!ok) errs.push({ start: m.index, end: m.index + m[0].length, msg: MSG_HEX });
}
var stack = [];
for (var i = 0; i < text.length; i++) {
var ch = text.charAt(i);
if (ch === '{' || ch === '(' || ch === '[') stack.push(i);
else if (ch === '}' || ch === ')' || ch === ']') {
if (stack.length) stack.pop();
else errs.push({ start: i, end: i + 1, msg: MSG_BRACKET });
}
}
stack.forEach(function (p) { errs.push({ start: p, end: p + 1, msg: MSG_BRACKET }); });
errs.sort(function (a, b) { return a.start - b.start; });
return errs;
}
function esc(t) { return t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
// 투명 textarea 아래 pre 를 다시 그린다. 주석 줄은 흐리게, 오류 범위는 물결 span 으로 감싼다
function render() {
var text = ta.value;
errsCache = lint(text);
var lines = text.split('\n');
lineStarts = [0];
lines.forEach(function (l, i) { if (i) lineStarts.push(lineStarts[i - 1] + lines[i - 1].length + 1); });
hl.innerHTML = lines.map(function (line, ln) {
var lead = lineStarts[ln];
// 주석 줄 판정은 정규식으로 한다 — 소스에 여는 주석 문자열을 리터럴로 남기면 이 파일을 읽는
// 도구(게이트의 주석 제거기)가 거기서 주석이 열린 줄 알고 뒤를 통째로 삼킨다 (실측 2026-09-13)
var com = /^\s*\/\*/.test(line);
var local = [];
errsCache.forEach(function (e, ei) {
if (e.start >= lead && e.end <= lead + line.length) local.push({ s: e.start - lead, t: e.end - lead, ei: ei });
});
var out = '', pos = 0;
local.forEach(function (x) {
out += esc(line.slice(pos, x.s)) + '<span class="lint__err" data-e="' + x.ei + '">' + esc(line.slice(x.s, x.t)) + '</span>';
pos = x.t;
});
out += esc(line.slice(pos));
return '<span class="lint__ln' + (com ? ' lint__ln--com' : '') + '">' + (out || '') + '</span>';
}).join('');
placeTips();
chip.dataset.n = String(errsCache.length);
count.textContent = COUNT_TPL.replace('{n}', String(errsCache.length));
}
// 한 글자 폭과 줄 높이를 pre 안에서 실측한다 — 폰 폭에서 서체 크기가 줄어도 따라간다
function metric() {
var probe = document.createElement('span');
probe.className = 'lint__probe';
probe.textContent = '0000000000';
hl.appendChild(probe);
var cs = getComputedStyle(hl);
var M = { w: probe.getBoundingClientRect().width / 10, lh: parseFloat(cs.lineHeight), x: parseFloat(cs.paddingLeft), y: parseFloat(cs.paddingTop) };
probe.remove();
return M;
}
// 툴팁 두 개를 각 오류 줄 위(래핑 상자 안)에 둔다 — 아래로 삐져나오면 scrollHeight 가 커진다.
// 가로는 오른쪽 끝을 넘지 않게 묶는다.
function placeTips() {
var M = metric();
errsCache.slice(0, TIP.length).forEach(function (e, i) {
var ln = 0;
while (ln + 1 < lineStarts.length && lineStarts[ln + 1] <= e.start) ln++;
var tip = TIP[i];
tip.innerHTML = '<b>' + e.msg[0] + '</b>' + e.msg[1];
tip.style.left = '0px';
var left = M.x + (e.start - lineStarts[ln]) * M.w;
var top = M.y + ln * M.lh - tip.offsetHeight - 4;
var maxL = wrap.clientWidth - tip.offsetWidth - 4;
tip.style.left = Math.max(4, Math.min(left, maxL)) + 'px';
tip.style.top = Math.max(0, top) + 'px';
tip.classList.remove('is-open');
});
}
// 포인터 위치를 글자 인덱스로 바꾼다 — 고정폭 서체라 한 글자 폭으로 열을 잃는다
function charIndex(px, py) {
var M = metric();
var ln = Math.floor((py - M.y) / M.lh);
if (ln < 0 || ln >= lineStarts.length) return -1;
var col = Math.round((px - M.x) / M.w);
var lineLen = ta.value.split('\n')[ln].length;
return lineStarts[ln] + Math.max(0, Math.min(col, lineLen));
}
function openAt(idx) {
var hit = -1;
errsCache.forEach(function (e, i) { if (idx >= e.start && idx <= e.end) hit = i; });
TIP.forEach(function (t, i) { t.classList.toggle('is-open', i === hit); });
}
ta.addEventListener('input', function () { lintEl.classList.remove('is-demo'); render(); });
ta.addEventListener('scroll', function () { hl.scrollTop = ta.scrollTop; hl.scrollLeft = ta.scrollLeft; });
ta.addEventListener('focus', function () { lintEl.classList.remove('is-demo'); });
ta.addEventListener('pointermove', function (ev) {
var r = ta.getBoundingClientRect();
openAt(charIndex(ev.clientX - r.left, ev.clientY - r.top));
});
ta.addEventListener('pointerleave', function () { TIP.forEach(function (t) { t.classList.remove('is-open'); }); });
// 키보드로도 이유를 본다 — 커서가 오류 범위에 있으면 그 툴팁이 열린다
ta.addEventListener('keyup', function () { openAt(ta.selectionStart); });
wrap.addEventListener('pointerdown', function () { lintEl.classList.remove('is-demo'); });
// 창 폭이 바뀌면 줄 높이가 달라진다 — 툴팁 자리를 다시 잡는다
window.addEventListener('resize', placeTips);
render();// LintUnderlineEditor — vanilla 판(run/299/demo/08-editor-lint-error-underline/index.html) 의 React 포트.
// 글자를 투명하게 만든 textarea 를 같은 자리·같은 서체의 pre 위에 얹고, 간이 린트가 잡은 범위만
// 물결 밑줄 span 으로 감싼다. 린트 규칙 둘(괄호 짝·색 코드 자릿수)과 그 순서는 vanilla 와 같은 글자다.
// 접근성 경로는 툴팁이 아니라 상태 줄이다 — textarea 의 aria-describedby 가 상태 줄을 가리킨다.
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LintUnderlineEditor.module.scss";
export type LintUnderlineEditorProps = {
duration?: number;
easing?: string;
color?: string;
initialCode?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef<T> 는 T | null 을 돌려준다 — ambient 타입에 맞춰 상자 타입을 따로 둔다
type Box<T> = { current: T | null };
// 이벤트 파라미터는 로컬 타입으로 받는다 — DOM 전역 타입을 "react" 에서 가져오면 tsc 가 죽는다
type PointerEvt = { clientX: number; clientY: number };
const LABEL = "코드 입력칸";
const HINT = "물결 위에 올려 보세요";
const FILE_NAME = "style.scss";
const COUNT_TPL = "{n} 오류";
const MSG_BRACKET = ["닫히지 않은 괄호", "열린 괄호에 닫는 짝이 없습니다"];
const MSG_HEX = ["자릿수가 틀린 색 코드", "16진수와 3·4·6·8 자리만 유효합니다"];
const HEX_DIGITS = [3, 4, 6, 8]; // 유효한 색 코드 자릿수. 4자리(#ff41 처럼 알파 1바이트)도 유효하고 5·7자리가 아니다
const STATUS_ID = "lintStatus";
const TIP_SLOTS = 2;
const CODE = [
"/* badge colors */",
".badge {",
" color: #ff41;",
"}",
".card {",
" color: #ff412;",
"",
].join("\n");
type LintErr = { start: number; end: number; msg: string[] };
type Metric = { w: number; lh: number; x: number; y: number };
// 간이 린트 — 두 규칙만. ① 색 코드: # 뒤가 16진수이고 자릿수가 3·4·6·8 중 하나여야 한다
// (# 뒤에 16진수가 아닌 글자가 붙어도 묶어서 오류로 잡는다). ② 괄호 짝: 스택으로 짝을 짓고
// 끝까지 안 닫힌 여는 괄호를 오류로 남긴다. 결과는 글자 인덱스 오름차순.
function lint(text: string): LintErr[] {
const errs: LintErr[] = [];
const hexRe = /#[0-9a-zA-Z]+/g;
let m = hexRe.exec(text);
while (m !== null) {
const body = m[0].slice(1);
const ok = /^[0-9a-fA-F]+$/.test(body) && HEX_DIGITS.indexOf(body.length) >= 0;
if (!ok) errs.push({ start: m.index, end: m.index + m[0].length, msg: MSG_HEX });
m = hexRe.exec(text);
}
const stack: number[] = [];
for (let i = 0; i < text.length; i++) {
const ch = text.charAt(i);
if (ch === "{" || ch === "(" || ch === "[") stack.push(i);
else if (ch === "}" || ch === ")" || ch === "]") {
if (stack.length) stack.pop();
else errs.push({ start: i, end: i + 1, msg: MSG_BRACKET });
}
}
stack.forEach((p) => errs.push({ start: p, end: p + 1, msg: MSG_BRACKET }));
errs.sort((a, b) => a.start - b.start);
return errs;
}
function lineStartsOf(text: string): number[] {
const lines = text.split("\n");
const starts = [0];
lines.forEach((l, i) => {
if (i) starts.push(starts[i - 1] + lines[i - 1].length + 1);
});
return starts;
}
function escapeHtml(text: string): string {
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
// 투명 textarea 아래 pre 를 다시 그린다. 주석 줄은 흐리게, 오류 범위는 물결 span 으로 감싼다
function paint(text: string, errs: LintErr[], starts: number[]): string {
return text
.split("\n")
.map((line, ln) => {
const lead = starts[ln];
const com = line.replace(/^\s+/, "").indexOf("/*") === 0;
const local: { s: number; t: number; ei: number }[] = [];
errs.forEach((e, ei) => {
if (e.start >= lead && e.end <= lead + line.length) local.push({ s: e.start - lead, t: e.end - lead, ei: ei });
});
let out = "";
let pos = 0;
local.forEach((x) => {
out += escapeHtml(line.slice(pos, x.s)) + '<span class="' + styles.err + '" data-e="' + x.ei + '">' +
escapeHtml(line.slice(x.s, x.t)) + "</span>";
pos = x.t;
});
out += escapeHtml(line.slice(pos));
const cls = styles.ln + (com ? " " + styles["ln--com"] : "");
return '<span class="' + cls + '">' + (out || "") + "</span>";
})
.join("");
}
// 한 글자 폭과 줄 높이를 pre 안에서 실측한다 — 폰 폭에서 서체 크기가 줄어도 따라간다
function metric(hl: HTMLPreElement): Metric {
const probe = document.createElement("span");
probe.className = styles.probe;
probe.textContent = "0000000000";
hl.appendChild(probe);
const cs = getComputedStyle(hl);
const m: Metric = {
w: probe.getBoundingClientRect().width / 10,
lh: parseFloat(cs.lineHeight),
x: parseFloat(cs.paddingLeft),
y: parseFloat(cs.paddingTop),
};
probe.remove();
return m;
}
export default function LintUnderlineEditor(props: LintUnderlineEditorProps) {
const duration = props.duration ?? 2000;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#ffd23f";
const [code, setCode] = useState<string>(props.initialCode ?? CODE);
const [preview, setPreview] = useState<boolean>(true);
const [openIdx, setOpenIdx] = useState<number>(-1);
const wrapRef = useRef<HTMLDivElement>(null) as Box<HTMLDivElement>;
const hlRef = useRef<HTMLPreElement>(null) as Box<HTMLPreElement>;
const taRef = useRef<HTMLTextAreaElement>(null) as Box<HTMLTextAreaElement>;
const tipARef = useRef<HTMLDivElement>(null) as Box<HTMLDivElement>;
const tipBRef = useRef<HTMLDivElement>(null) as Box<HTMLDivElement>;
const errs = useMemo(() => lint(code), [code]);
const starts = useMemo(() => lineStartsOf(code), [code]);
const html = useMemo(() => paint(code, errs, starts), [code, errs, starts]);
// 툴팁 두 개를 각 오류 줄 위(래핑 상자 안)에 둔다 — 아래로 삐져나오면 scrollHeight 가 커진다.
// 가로는 오른쪽 끝을 넘지 않게 묶는다.
useEffect(() => {
const place = () => {
const hl = hlRef.current;
const wrap = wrapRef.current;
if (!hl || !wrap) return;
const m = metric(hl);
const tips = [tipARef.current, tipBRef.current];
errs.slice(0, TIP_SLOTS).forEach((e, i) => {
const tip = tips[i];
if (!tip) return;
let ln = 0;
while (ln + 1 < starts.length && starts[ln + 1] <= e.start) ln++;
tip.style.left = "0px";
const left = m.x + (e.start - starts[ln]) * m.w;
const top = m.y + ln * m.lh - tip.offsetHeight - 4;
const maxL = wrap.clientWidth - tip.offsetWidth - 4;
tip.style.left = Math.max(4, Math.min(left, maxL)) + "px";
tip.style.top = Math.max(0, top) + "px";
});
};
place();
setOpenIdx(-1);
// 창 폭이 바뀌면 줄 높이가 달라진다 — 툴팁 자리를 다시 잡는다
window.addEventListener("resize", place);
return () => window.removeEventListener("resize", place);
}, [errs, starts]);
// 포인터 위치를 글자 인덱스로 바꾼다 — 고정폭 서체라 한 글자 폭으로 열을 잰다
const charIndex = (px: number, py: number): number => {
const hl = hlRef.current;
if (!hl) return -1;
const m = metric(hl);
const ln = Math.floor((py - m.y) / m.lh);
if (ln < 0 || ln >= starts.length) return -1;
const col = Math.round((px - m.x) / m.w);
const lineLen = code.split("\n")[ln].length;
return starts[ln] + Math.max(0, Math.min(col, lineLen));
};
const openAt = (idx: number) => {
let hit = -1;
errs.forEach((e, i) => {
if (i < TIP_SLOTS && idx >= e.start && idx <= e.end) hit = i;
});
setOpenIdx(hit);
};
const sync = () => {
const ta = taRef.current;
const hl = hlRef.current;
if (ta && hl) {
hl.scrollTop = ta.scrollTop;
hl.scrollLeft = ta.scrollLeft;
}
};
const tipClass = (i: number, extra: string) =>
styles.tip + " " + extra + (openIdx === i ? " " + styles.isOpen : "");
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const count = COUNT_TPL.replace("{n}", String(errs.length));
return (
<main className={styles.stage}>
<div className={styles.root + (preview ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2" />
<path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12" />
</g>
</svg>
</span>
<span className={styles.name}>{FILE_NAME}</span>
</div>
<div className={styles.wrap} ref={wrapRef} onPointerDown={() => setPreview(false)}>
<span className={styles.scan} aria-hidden="true" />
<pre className={styles.hl} ref={hlRef} aria-hidden="true" dangerouslySetInnerHTML={{ __html: html }} />
<div className={tipClass(0, styles.tipA)} ref={tipARef} aria-hidden="true">
{errs.length > 0 ? <b>{errs[0].msg[0]}</b> : null}
{errs.length > 0 ? errs[0].msg[1] : null}
</div>
<div className={tipClass(1, styles.tipB)} ref={tipBRef} aria-hidden="true">
{errs.length > 1 ? <b>{errs[1].msg[0]}</b> : null}
{errs.length > 1 ? errs[1].msg[1] : null}
</div>
<textarea
className={styles.ta}
ref={taRef}
value={code}
wrap="off"
spellCheck={false}
autoComplete="off"
aria-label={LABEL}
aria-describedby={STATUS_ID}
onChange={(e: { target: { value: string } }) => {
setPreview(false);
setCode(e.target.value);
}}
onScroll={sync}
onFocus={() => setPreview(false)}
onPointerMove={(ev: PointerEvt) => {
const ta = taRef.current;
if (!ta) return;
const r = ta.getBoundingClientRect();
openAt(charIndex(ev.clientX - r.left, ev.clientY - r.top));
}}
onPointerLeave={() => setOpenIdx(-1)}
// 키보드로도 이유를 본다 — 커서가 오류 범위에 있으면 그 툴팁이 열린다
onKeyUp={() => {
const ta = taRef.current;
if (ta) openAt(ta.selectionStart);
}}
/>
</div>
<p className={styles.status} id={STATUS_ID} role="status">
<span className={styles.chip} data-n={String(errs.length)}>
{/* 미리보기 중에는 이 글자를 CSS 가 감추고 아래 세 장을 컷으로 갈아 낸다 — 상태 글자는 건드리지 않는다 */}
<span className={styles.count}>{count}</span>
<span className={styles.countd} aria-hidden="true">
<b>0 오류</b>
<b>1 오류</b>
<b>2 오류</b>
</span>
</span>
<span className={styles.hint}>{HINT}</span>
</p>
</div>
</main>
);
}08 · Squiggly error underline — Measured area 32.71% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
A lint with exactly two rules
// A tiny lint — two rules only. (1) Hex color: the text after # must be hexadecimal and 3, 4, 6 or 8 digits long
// (a non-hex character stuck after # is caught as part of the same error). (2) Bracket pairs: match them on a stack
// and report every opening bracket left unclosed. Results come back in ascending character index.
function lint(text) {
var errs = [], m;
var hexRe = /#[0-9a-zA-Z]+/g;
while ((m = hexRe.exec(text)) !== null) {
var body = m[0].slice(1);
var ok = /^[0-9a-fA-F]+$/.test(body) && HEX_DIGITS.indexOf(body.length) >= 0;
if (!ok) errs.push({ start: m.index, end: m.index + m[0].length, msg: MSG_HEX });
}
var stack = [];
for (var i = 0; i < text.length; i++) {
var ch = text.charAt(i);
if (ch === '{' || ch === '(' || ch === '[') stack.push(i);
else if (ch === '}' || ch === ')' || ch === ']') {
if (stack.length) stack.pop();
else errs.push({ start: i, end: i + 1, msg: MSG_BRACKET });
}
}
stack.forEach(function (p) { errs.push({ start: p, end: p + 1, msg: MSG_BRACKET }); });
errs.sort(function (a, b) { return a.start - b.start; });
return errs;
}
Clean input reports 0 errors, a five-digit #ff412 reports 1 with a tooltip naming the digit count, and a stray { reports 1 for the unclosed pair. The underline is two CSS words, underline wavy, and the same count is written as text and tied to the input with aria-describedby so it is not carried by color alone.
09Editor theme switcher
Light, dark, and high-contrast swap one bundle of CSS variables, so the token colors change together with the chrome instead of lagging a step behind it.

<main class="stage">
<div class="th is-demo" id="th">
<div class="th__panel" id="panel" data-theme="light">
<div class="th__bar">
<span class="th__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 127.17a96.5 96.5 0 0 1-2.39 22.18a24 24 0 0 1-23.4 18.65H152a24 24 0 0 0-24 24a24 24 0 0 1-32 22.61c-37.27-13.17-64-44.8-64-86.61a96 96 0 0 1 95-96c52.84-.53 96.55 42.35 97 95.17" opacity=".2"/><path d="M200.77 53.89A103.27 103.27 0 0 0 128 24h-1.07A104 104 0 0 0 24 128c0 43 26.58 79.06 69.36 94.17A32 32 0 0 0 136 192a16 16 0 0 1 16-16h46.21a31.81 31.81 0 0 0 31.2-24.88a104.4 104.4 0 0 0 2.59-24a103.28 103.28 0 0 0-31.23-73.23m13 93.71a15.89 15.89 0 0 1-15.56 12.4H152a32 32 0 0 0-32 32a16 16 0 0 1-21.31 15.07C62.49 194.3 40 164 40 128a88 88 0 0 1 87.09-88h.9a88.35 88.35 0 0 1 88 87.25a89 89 0 0 1-2.18 20.35ZM140 76a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-44 24a12 12 0 1 1-12-12a12 12 0 0 1 12 12m0 56a12 12 0 1 1-12-12a12 12 0 0 1 12 12m88-56a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></span>
<span class="th__name">theme.scss</span>
<span class="th__cur" id="cur" data-cur="light"><span class="th__curName" data-set="light">Light</span><span class="th__curName" data-set="dark">Dark</span><span class="th__curName" data-set="hc">High contrast</span></span>
</div>
<pre class="th__code"><span class="t t--com">/* theme tokens */</span>
<span class="t t--k">.card</span> {
--brand: <span class="t t--s">"godrich"</span>;
box-shadow: <span class="t t--n">0 2px 12px</span>;
transition: opacity <span class="t t--n">220ms</span>;
}</pre>
</div>
<div class="th__opts" role="group" aria-label="Choose a theme">
<button class="th__opt" type="button" data-set="light" aria-pressed="true">
<span class="th__ind" aria-hidden="true"></span>
<span class="th__dots" aria-hidden="true"><i class="th__dot th__dot--1"></i><i class="th__dot th__dot--2"></i><i class="th__dot th__dot--3"></i></span>
<span class="th__lb">Light</span>
</button>
<button class="th__opt" type="button" data-set="dark" aria-pressed="false">
<span class="th__ind" aria-hidden="true"></span>
<span class="th__dots" aria-hidden="true"><i class="th__dot th__dot--1"></i><i class="th__dot th__dot--2"></i><i class="th__dot th__dot--3"></i></span>
<span class="th__lb">Dark</span>
</button>
<button class="th__opt" type="button" data-set="hc" aria-pressed="false">
<span class="th__ind" aria-hidden="true"></span>
<span class="th__dots" aria-hidden="true"><i class="th__dot th__dot--1"></i><i class="th__dot th__dot--2"></i><i class="th__dot th__dot--3"></i></span>
<span class="th__lb">High contrast</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 에디터 테마 스위처 — 라이트·다크·고대비 세 벌을 CSS 변수 한 묶음으로 갈아 끼운다.
// 패널의 [data-theme] 선택자 하나가 배면·글자·토큰 색(--tok-key·--tok-str·--tok-num·--tok-com)과
// color-scheme 까지 전부 정의하고, JS 는 그 속성 값만 바꾼다.
// 고대비 판은 글자마다 실제로 대비를 재서 4.5:1 이상으로 맞췄다(실측값 run/299/_probe/theme_contrast.json).
// 고대비에서는 색만이 아니라 굵기로도 토큰을 갈라 색에만 기대지 않게 했다.
// 격자 미리보기(.is-demo)는 테마 교체를 steps(1, end) 컷으로 한 프레임에 자른다 — 크로스페이드로
// 겹치면 poster 에 두 테마가 반투명하게 겹친 유령이 찍힌다(237 실측 계열). 프레임 수는 단추 밑
// 채움 막대(scaleX)가 채운다 — 반투명이 생기지 않는 움직임이다.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $stage-yellow;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$fs: clamp(11px, 3vw, 13px);
$lh: 20px;
// 색 변수를 등록한다 — 등록된 <color> 변수만 keyframes 에서 steps(1, end) 로 값이 딱 끊어진다.
// 초깃값은 라이트 벌(정적 기본 상태)과 같은 값이다
@property --ed-bg { syntax: '<color>'; inherits: true; initial-value: #{$stage-paper}; }
@property --ed-fg { syntax: '<color>'; inherits: true; initial-value: #{$subject-ink}; }
@property --tok-key { syntax: '<color>'; inherits: true; initial-value: #{$subject-blue-deep}; }
@property --tok-str { syntax: '<color>'; inherits: true; initial-value: #9d88c4; }
@property --tok-num { syntax: '<color>'; inherits: true; initial-value: #{$error}; }
@property --tok-com { syntax: '<color>'; inherits: true; initial-value: #6b6577; }
@include stage($stage-ink);
.th { display: flex; flex-direction: column; gap: $sp-2; width: min(100%, 400px); }
// 세 벌의 변수 묶음. 라이트의 문자열 색은 흰 면 위 4.5:1 을 넘기도록 토큰보다 한 단계
// 내린 값이다(실측값은 theme_contrast.json). 검은 면은 토큰에 없는 색이라 리터럴로 쓴다.
// 사용자 정의 속성값은 SassScript 가 아닌 CSS 값으로 파싱된다 — 토큰은 #{} 보간으로 넣는다
.th__panel[data-theme="light"] {
--ed-bg: #{$stage-paper}; --ed-fg: #{$subject-ink};
--tok-key: #{$subject-blue-deep}; --tok-str: #9d88c4; --tok-num: #{$error}; --tok-com: #6b6577;
color-scheme: light;
}
.th__panel[data-theme="dark"], .th__panel[data-theme="hc"] {
--tok-key: #{$stage-yellow}; --tok-str: #{$tick-up}; --tok-num: #{$subject-sky};
color-scheme: dark;
}
.th__panel[data-theme="dark"] {
--ed-bg: #{$tick-panel}; --ed-fg: #{$subject-cream}; --tok-com: #{$tick-mute};
}
.th__panel[data-theme="hc"] {
--ed-bg: #000000; --ed-fg: #{$stage-paper}; --tok-com: #cfc8d9;
}
.th__panel {
box-sizing: border-box; display: flex; flex-direction: column; gap: $sp-2;
padding: $sp-3; border: 1px solid rgba(255, 255, 255, .18); border-radius: $r-card;
background: var(--ed-bg); color: var(--ed-fg);
box-shadow: $shadow-raised;
}
// ── 파일 이름 줄 — 아이콘·테두리도 토큰 색을 따라가니 테마 바뀌면 같이 바뀐다
.th__bar { display: flex; align-items: center; gap: $sp-2; }
.th__ico { display: grid; place-items: center; color: var(--tok-key); font-size: 15px; }
.th__name { flex: 1 1 auto; font-family: $mono; font-weight: 700; font-size: 12px; line-height: 1.4; }
.th__cur {
display: inline-grid; padding: $sp-1 $sp-2; border: 1px solid var(--tok-key); border-radius: $r-pill;
font-family: $mono; font-weight: 700; font-size: 11px; line-height: 1.4;
}
// 이름표 세 장을 한 칸에 겹친다 — 칸 폭이 가장 긴 이름에 맞춰 고정돼 테마를 갈아도 바가 안 흔들린다
.th__curName { grid-area: 1 / 1; visibility: hidden; }
.th__cur[data-cur="light"] .th__curName[data-set="light"],
.th__cur[data-cur="dark"] .th__curName[data-set="dark"],
.th__cur[data-cur="hc"] .th__curName[data-set="hc"] { visibility: visible; }
.th__code {
margin: 0; padding: $sp-2; border-radius: $r-sm;
background: rgba(0, 0, 0, .05);
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
letter-spacing: 0; tab-size: 2;
white-space: pre; overflow: auto; overscroll-behavior: contain;
}
.t--k { color: var(--tok-key); }
.t--s { color: var(--tok-str); }
.t--n { color: var(--tok-num); }
.t--com { color: var(--tok-com); }
// 고대비에서는 코드를 굵게, 주석만 보통 굵기로 — 색이 아니라 굵기로도 갈라 읽는다
.th__panel[data-theme="hc"] .th__code { font-weight: 700; }
.th__panel[data-theme="hc"] .t--com { font-weight: 500; }
// ── 테마 단추 세 칸. 격자는 이 컨테이너가 쓴다 — 단추에 display:grid 를 주면 폭이
// fit-content 로 접혀 1fr 칸이 0 이 된다(281-04 실측). 단추 안은 flex 다
.th__opts { display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-2; }
.th__opt {
position: relative; display: flex; align-items: center; justify-content: center; gap: $sp-1;
box-sizing: border-box; width: 100%; padding: $sp-1 $sp-2 $sp-2;
border: 1px solid rgba(255, 255, 255, .16); border-radius: $r-control;
background: rgba(255, 255, 255, .08); color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.4;
cursor: pointer;
}
.th__opt[aria-pressed="true"] { background: $subject-cream; color: $subject-ink; border-color: $subject-cream; }
.th__opt:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 각 단추의 색 견본 — 그 테마의 배면·키워드·글자 색 세 점
.th__dots { display: inline-flex; gap: $sp-1; }
.th__dot { width: 8px; height: 8px; border: 1px solid rgba(255, 247, 230, .4); border-radius: 50%; box-sizing: border-box; }
.th__opt[data-set="light"] .th__dot--1 { background: $stage-paper; }
.th__opt[data-set="light"] .th__dot--2 { background: $subject-blue-deep; }
.th__opt[data-set="light"] .th__dot--3 { background: #9d88c4; }
.th__opt[data-set="dark"] .th__dot--1 { background: $tick-panel; }
.th__opt[data-set="dark"] .th__dot--2 { background: $stage-yellow; }
.th__opt[data-set="dark"] .th__dot--3 { background: $tick-up; }
.th__opt[data-set="hc"] .th__dot--1 { background: #000000; }
.th__opt[data-set="hc"] .th__dot--2 { background: $stage-yellow; }
.th__opt[data-set="hc"] .th__dot--3 { background: $stage-paper; }
// 눌린 단추 밑 채움 막대 — 채움 막대는 시작 변에서 자라므로 origin 을 왼쪽에 둔다
// (blog-demo 규칙 8의 "붙는 지점이 있는 요소" 예외). 눌린 단추는 밝은 면이라 막대를 먹색으로
.th__ind {
position: absolute; left: $sp-2; right: $sp-2; bottom: $sp-1; height: $sp-1;
border-radius: $r-pill; background: $color;
transform: scaleX(0);
transform-origin: left center;
}
.th__opt[aria-pressed="true"] .th__ind { transform: scaleX(1); background: $subject-ink; }
// ── 격자 미리보기 루프(2초). 10% 에 다크, 40% 에 고대비, 70% 에 라이트로 돌아온다 —
// 복귀가 루프 안 70% 에 있으므로 24프레임 샘플 안에 다 잡힌다.
// 패널 색 여섯 개는 등록된 변수라 steps 컷으로 딱 끊어지고, 단추 배면·채움 막대가 같은 박자를 따른다
.th.is-demo .th__panel {
animation-name: thBg, thFg, thKey, thStr, thNum, thCom;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.th.is-demo .th__opt {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.th.is-demo .th__curName {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.th.is-demo .th__curName[data-set="light"] { animation-name: thName1; }
.th.is-demo .th__curName[data-set="dark"] { animation-name: thName2; }
.th.is-demo .th__curName[data-set="hc"] { animation-name: thName3; }
.th.is-demo .th__opt:nth-child(1) { animation-name: thOpt1; }
.th.is-demo .th__opt:nth-child(2) { animation-name: thOpt2; }
.th.is-demo .th__opt:nth-child(3) { animation-name: thOpt3; }
.th.is-demo .th__ind {
animation-duration: $duration;
animation-iteration-count: infinite;
}
.th.is-demo .th__opt:nth-child(1) .th__ind { animation-name: thInd1; }
.th.is-demo .th__opt:nth-child(2) .th__ind { animation-name: thInd2; }
.th.is-demo .th__opt:nth-child(3) .th__ind { animation-name: thInd3; }
@keyframes thBg {
0%, 9% { --ed-bg: #{$stage-paper}; }
10%, 39% { --ed-bg: #{$tick-panel}; }
40%, 69% { --ed-bg: #000000; }
70%, 100% { --ed-bg: #{$stage-paper}; }
}
@keyframes thFg {
0%, 9% { --ed-fg: #{$subject-ink}; }
10%, 39% { --ed-fg: #{$subject-cream}; }
40%, 69% { --ed-fg: #{$stage-paper}; }
70%, 100% { --ed-fg: #{$subject-ink}; }
}
@keyframes thKey {
0%, 9% { --tok-key: #{$subject-blue-deep}; }
10%, 100% { --tok-key: #{$stage-yellow}; }
}
@keyframes thStr {
0%, 9% { --tok-str: #9d88c4; }
10%, 100% { --tok-str: #{$tick-up}; }
}
@keyframes thNum {
0%, 9% { --tok-num: #{$error}; }
10%, 100% { --tok-num: #{$subject-sky}; }
}
@keyframes thCom {
0%, 9% { --tok-com: #6b6577; }
10%, 39% { --tok-com: #{$tick-mute}; }
40%, 69% { --tok-com: #cfc8d9; }
70%, 100% { --tok-com: #6b6577; }
}
// 이름표도 단추와 **같은 순간**에 갈아 끼운다 — 어긋나면 「라이트 뱃지 + 다크 화면」 장면이 poster 로 뽑힌다
// (실측 2026-09-13: 이름표를 JS 로만 바꾸다 보니 미리보기에서 영원히 「라이트」였다)
@keyframes thName1 {
0%, 9% { visibility: visible; }
10%, 69% { visibility: hidden; }
70%, 100% { visibility: visible; }
}
@keyframes thName2 {
0%, 9% { visibility: hidden; }
10%, 39% { visibility: visible; }
40%, 100% { visibility: hidden; }
}
@keyframes thName3 {
0%, 39% { visibility: hidden; }
40%, 69% { visibility: visible; }
70%, 100% { visibility: hidden; }
}
@keyframes thOpt1 {
0%, 9% { background: $subject-cream; color: $subject-ink; }
10%, 69% { background: rgba(255, 255, 255, .08); color: $subject-cream; }
70%, 100% { background: $subject-cream; color: $subject-ink; }
}
@keyframes thOpt2 {
0%, 9% { background: rgba(255, 255, 255, .08); color: $subject-cream; }
10%, 39% { background: $subject-cream; color: $subject-ink; }
40%, 100% { background: rgba(255, 255, 255, .08); color: $subject-cream; }
}
@keyframes thOpt3 {
0%, 39% { background: rgba(255, 255, 255, .08); color: $subject-cream; }
40%, 69% { background: $subject-cream; color: $subject-ink; }
70%, 100% { background: rgba(255, 255, 255, .08); color: $subject-cream; }
}
// 채움 막대 — 컷(접기)은 steps, 자라기는 튕김 이징: 키프레임 안에서 이징을 따로 준다.
// 접을 때 튕김을 주면 scaleX 가 음수로 넘겨 반쪽 막대가 번쩍인다
@keyframes thInd1 {
0%, 9% { transform: scaleX(1); background: $subject-ink; animation-timing-function: steps(1, end); }
10%, 69% { transform: scaleX(0); background: $color; animation-timing-function: steps(1, end); }
70% { transform: scaleX(0); background: $subject-ink; animation-timing-function: #{$easing}; }
86%, 100% { transform: scaleX(1); background: $subject-ink; }
}
@keyframes thInd2 {
0%, 9% { transform: scaleX(0); background: $color; animation-timing-function: steps(1, end); }
10% { transform: scaleX(0); background: $subject-ink; animation-timing-function: #{$easing}; }
26%, 39% { transform: scaleX(1); background: $subject-ink; animation-timing-function: steps(1, end); }
40%, 100% { transform: scaleX(0); background: $color; }
}
@keyframes thInd3 {
0%, 39% { transform: scaleX(0); background: $color; animation-timing-function: steps(1, end); }
40% { transform: scaleX(0); background: $subject-ink; animation-timing-function: #{$easing}; }
56%, 69% { transform: scaleX(1); background: $subject-ink; animation-timing-function: steps(1, end); }
70%, 100% { transform: scaleX(0); background: $color; }
}
// ── 좁은 화면: 행간과 여백을 줄여 무대 안에 들어온다
@media (max-width: 340px) {
.th { gap: $sp-1; }
.th__panel { padding: $sp-2; gap: $sp-1; }
.th__code { font-size: 11px; line-height: 16px; padding: $sp-1; }
.th__name { font-size: 11px; }
.th__cur { font-size: 10px; padding: 0 $sp-1; } // 이름표를 겹치면서 칸이 두 줄 몫으로 커졌다 — 세로 여백을 0 으로
.th__opts { gap: $sp-1; }
.th__opt { font-size: 10px; padding: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
// 애니메이션을 끊으면 정적 규칙(라이트 벌 + 눌린 첫 단추)이 그대로 드러난다
.th.is-demo .th__panel,
.th.is-demo .th__curName,
.th.is-demo .th__opt,
.th.is-demo .th__ind {
animation: none !important;
}
}@property --ed-bg {
syntax: "<color>";
inherits: true;
initial-value: #ffffff;
}
@property --ed-fg {
syntax: "<color>";
inherits: true;
initial-value: #17141a;
}
@property --tok-key {
syntax: "<color>";
inherits: true;
initial-value: #0a4fe8;
}
@property --tok-str {
syntax: "<color>";
inherits: true;
initial-value: #9d88c4;
}
@property --tok-num {
syntax: "<color>";
inherits: true;
initial-value: #c2361a;
}
@property --tok-com {
syntax: "<color>";
inherits: true;
initial-value: #6b6577;
}
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);
}
.th {
display: flex;
flex-direction: column;
gap: 8px;
width: min(100%, 400px);
}
.th__panel[data-theme=light] {
--ed-bg: #ffffff;
--ed-fg: #17141a;
--tok-key: #0a4fe8;
--tok-str: #9d88c4;
--tok-num: #c2361a;
--tok-com: #6b6577;
color-scheme: light;
}
.th__panel[data-theme=dark], .th__panel[data-theme=hc] {
--tok-key: #ffd23f;
--tok-str: #3ddc97;
--tok-num: #6ec8ff;
color-scheme: dark;
}
.th__panel[data-theme=dark] {
--ed-bg: #231f28;
--ed-fg: #fff7e6;
--tok-com: #9a93a6;
}
.th__panel[data-theme=hc] {
--ed-bg: #000000;
--ed-fg: #ffffff;
--tok-com: #cfc8d9;
}
.th__panel {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 16px;
background: var(--ed-bg);
color: var(--ed-fg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.th__bar {
display: flex;
align-items: center;
gap: 8px;
}
.th__ico {
display: grid;
place-items: center;
color: var(--tok-key);
font-size: 15px;
}
.th__name {
flex: 1 1 auto;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
}
.th__cur {
display: inline-grid;
padding: 4px 8px;
border: 1px solid var(--tok-key);
border-radius: 999px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
}
.th__curName {
grid-area: 1/1;
visibility: hidden;
}
.th__cur[data-cur=light] .th__curName[data-set=light],
.th__cur[data-cur=dark] .th__curName[data-set=dark],
.th__cur[data-cur=hc] .th__curName[data-set=hc] {
visibility: visible;
}
.th__code {
margin: 0;
padding: 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.05);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
letter-spacing: 0;
tab-size: 2;
white-space: pre;
overflow: auto;
overscroll-behavior: contain;
}
.t--k {
color: var(--tok-key);
}
.t--s {
color: var(--tok-str);
}
.t--n {
color: var(--tok-num);
}
.t--com {
color: var(--tok-com);
}
.th__panel[data-theme=hc] .th__code {
font-weight: 700;
}
.th__panel[data-theme=hc] .t--com {
font-weight: 500;
}
.th__opts {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.th__opt {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
box-sizing: border-box;
width: 100%;
padding: 4px 8px 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.th__opt[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
border-color: #fff7e6;
}
.th__opt:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.th__dots {
display: inline-flex;
gap: 4px;
}
.th__dot {
width: 8px;
height: 8px;
border: 1px solid rgba(255, 247, 230, 0.4);
border-radius: 50%;
box-sizing: border-box;
}
.th__opt[data-set=light] .th__dot--1 {
background: #ffffff;
}
.th__opt[data-set=light] .th__dot--2 {
background: #0a4fe8;
}
.th__opt[data-set=light] .th__dot--3 {
background: #9d88c4;
}
.th__opt[data-set=dark] .th__dot--1 {
background: #231f28;
}
.th__opt[data-set=dark] .th__dot--2 {
background: #ffd23f;
}
.th__opt[data-set=dark] .th__dot--3 {
background: #3ddc97;
}
.th__opt[data-set=hc] .th__dot--1 {
background: #000000;
}
.th__opt[data-set=hc] .th__dot--2 {
background: #ffd23f;
}
.th__opt[data-set=hc] .th__dot--3 {
background: #ffffff;
}
.th__ind {
position: absolute;
left: 8px;
right: 8px;
bottom: 4px;
height: 4px;
border-radius: 999px;
background: #ffd23f;
transform: scaleX(0);
transform-origin: left center;
}
.th__opt[aria-pressed=true] .th__ind {
transform: scaleX(1);
background: #17141a;
}
.th.is-demo .th__panel {
animation-name: thBg, thFg, thKey, thStr, thNum, thCom;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.th.is-demo .th__opt {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.th.is-demo .th__curName {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.th.is-demo .th__curName[data-set=light] {
animation-name: thName1;
}
.th.is-demo .th__curName[data-set=dark] {
animation-name: thName2;
}
.th.is-demo .th__curName[data-set=hc] {
animation-name: thName3;
}
.th.is-demo .th__opt:nth-child(1) {
animation-name: thOpt1;
}
.th.is-demo .th__opt:nth-child(2) {
animation-name: thOpt2;
}
.th.is-demo .th__opt:nth-child(3) {
animation-name: thOpt3;
}
.th.is-demo .th__ind {
animation-duration: 2s;
animation-iteration-count: infinite;
}
.th.is-demo .th__opt:nth-child(1) .th__ind {
animation-name: thInd1;
}
.th.is-demo .th__opt:nth-child(2) .th__ind {
animation-name: thInd2;
}
.th.is-demo .th__opt:nth-child(3) .th__ind {
animation-name: thInd3;
}
@keyframes thBg {
0%, 9% {
--ed-bg: #ffffff;
}
10%, 39% {
--ed-bg: #231f28;
}
40%, 69% {
--ed-bg: #000000;
}
70%, 100% {
--ed-bg: #ffffff;
}
}
@keyframes thFg {
0%, 9% {
--ed-fg: #17141a;
}
10%, 39% {
--ed-fg: #fff7e6;
}
40%, 69% {
--ed-fg: #ffffff;
}
70%, 100% {
--ed-fg: #17141a;
}
}
@keyframes thKey {
0%, 9% {
--tok-key: #0a4fe8;
}
10%, 100% {
--tok-key: #ffd23f;
}
}
@keyframes thStr {
0%, 9% {
--tok-str: #9d88c4;
}
10%, 100% {
--tok-str: #3ddc97;
}
}
@keyframes thNum {
0%, 9% {
--tok-num: #c2361a;
}
10%, 100% {
--tok-num: #6ec8ff;
}
}
@keyframes thCom {
0%, 9% {
--tok-com: #6b6577;
}
10%, 39% {
--tok-com: #9a93a6;
}
40%, 69% {
--tok-com: #cfc8d9;
}
70%, 100% {
--tok-com: #6b6577;
}
}
@keyframes thName1 {
0%, 9% {
visibility: visible;
}
10%, 69% {
visibility: hidden;
}
70%, 100% {
visibility: visible;
}
}
@keyframes thName2 {
0%, 9% {
visibility: hidden;
}
10%, 39% {
visibility: visible;
}
40%, 100% {
visibility: hidden;
}
}
@keyframes thName3 {
0%, 39% {
visibility: hidden;
}
40%, 69% {
visibility: visible;
}
70%, 100% {
visibility: hidden;
}
}
@keyframes thOpt1 {
0%, 9% {
background: #fff7e6;
color: #17141a;
}
10%, 69% {
background: rgba(255, 255, 255, 0.08);
color: #fff7e6;
}
70%, 100% {
background: #fff7e6;
color: #17141a;
}
}
@keyframes thOpt2 {
0%, 9% {
background: rgba(255, 255, 255, 0.08);
color: #fff7e6;
}
10%, 39% {
background: #fff7e6;
color: #17141a;
}
40%, 100% {
background: rgba(255, 255, 255, 0.08);
color: #fff7e6;
}
}
@keyframes thOpt3 {
0%, 39% {
background: rgba(255, 255, 255, 0.08);
color: #fff7e6;
}
40%, 69% {
background: #fff7e6;
color: #17141a;
}
70%, 100% {
background: rgba(255, 255, 255, 0.08);
color: #fff7e6;
}
}
@keyframes thInd1 {
0%, 9% {
transform: scaleX(1);
background: #17141a;
animation-timing-function: steps(1, end);
}
10%, 69% {
transform: scaleX(0);
background: #ffd23f;
animation-timing-function: steps(1, end);
}
70% {
transform: scaleX(0);
background: #17141a;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
}
86%, 100% {
transform: scaleX(1);
background: #17141a;
}
}
@keyframes thInd2 {
0%, 9% {
transform: scaleX(0);
background: #ffd23f;
animation-timing-function: steps(1, end);
}
10% {
transform: scaleX(0);
background: #17141a;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
}
26%, 39% {
transform: scaleX(1);
background: #17141a;
animation-timing-function: steps(1, end);
}
40%, 100% {
transform: scaleX(0);
background: #ffd23f;
}
}
@keyframes thInd3 {
0%, 39% {
transform: scaleX(0);
background: #ffd23f;
animation-timing-function: steps(1, end);
}
40% {
transform: scaleX(0);
background: #17141a;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
}
56%, 69% {
transform: scaleX(1);
background: #17141a;
animation-timing-function: steps(1, end);
}
70%, 100% {
transform: scaleX(0);
background: #ffd23f;
}
}
@media (max-width: 340px) {
.th {
gap: 4px;
}
.th__panel {
padding: 8px;
gap: 4px;
}
.th__code {
font-size: 11px;
line-height: 16px;
padding: 4px;
}
.th__name {
font-size: 11px;
}
.th__cur {
font-size: 10px;
padding: 0 4px;
}
.th__opts {
gap: 4px;
}
.th__opt {
font-size: 10px;
padding: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.th.is-demo .th__panel,
.th.is-demo .th__curName,
.th.is-demo .th__opt,
.th.is-demo .th__ind {
animation: none !important;
}
}var th = document.getElementById('th');
var panel = document.getElementById('panel');
var cur = document.getElementById('cur');
var opts = Array.prototype.slice.call(document.querySelectorAll('.th__opt'));
// 단추를 누르면 data-theme 하나만 바꾼다 — 색 뭉치는 CSS 변수 선택자가 갈아 끼운다
opts.forEach(function (btn) {
btn.addEventListener('click', function () {
th.classList.remove('is-demo');
panel.dataset.theme = btn.dataset.set;
// 이름표 세 장을 한 칸에 겹쳐 두고 보이는 것만 고른다 — 미리보기 루프도 같은 칸을 컷으로 갈아 끼운다
cur.dataset.cur = btn.dataset.set;
opts.forEach(function (b) { b.setAttribute('aria-pressed', String(b === btn)); });
});
});
th.addEventListener('pointerdown', function () { th.classList.remove('is-demo'); });// EditorThemeSwitcher — vanilla 판(run/299/demo/09-editor-theme-switcher/index.html) 의 React 포트.
// 단추를 누르면 패널의 data-theme 하나만 바뀐다 — 색 뭉치는 CSS 변수 선택자가 갈아 끼우므로
// 테마 토큰(--ed-bg·--ed-fg·--tok-key·--tok-str·--tok-num·--tok-com)은 props 가 아니라 CSS 가 정한다.
// 이름표 세 장은 한 칸에 겹쳐 두고 data-cur 로 보이는 것만 고른다 — textContent 를 갈아 끼우지 않는다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EditorThemeSwitcher.module.scss";
export type EditorThemeSwitcherProps = {
duration?: number;
easing?: string;
color?: string;
initialTheme?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const FILE_NAME = "theme.scss";
const GROUP_LABEL = "테마 선택";
const THEMES = [
{ set: "light", label: "라이트" },
{ set: "dark", label: "다크" },
{ set: "hc", label: "고대비" },
];
export default function EditorThemeSwitcher(props: EditorThemeSwitcherProps) {
const duration = props.duration ?? 2000;
const easing = props.easing ?? "cubic-bezier(.2, 1.4, .4, 1)";
const color = props.color ?? "#ffd23f";
const [theme, setTheme] = useState<string>(props.initialTheme ?? "light");
const [preview, setPreview] = useState<boolean>(true);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div
className={styles.root + (preview ? " " + styles.isDemo : "")}
style={vars}
onPointerDown={() => setPreview(false)}
>
<div className={styles.panel} data-theme={theme}>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M224 127.17a96.5 96.5 0 0 1-2.39 22.18a24 24 0 0 1-23.4 18.65H152a24 24 0 0 0-24 24a24 24 0 0 1-32 22.61c-37.27-13.17-64-44.8-64-86.61a96 96 0 0 1 95-96c52.84-.53 96.55 42.35 97 95.17" opacity=".2" />
<path d="M200.77 53.89A103.27 103.27 0 0 0 128 24h-1.07A104 104 0 0 0 24 128c0 43 26.58 79.06 69.36 94.17A32 32 0 0 0 136 192a16 16 0 0 1 16-16h46.21a31.81 31.81 0 0 0 31.2-24.88a104.4 104.4 0 0 0 2.59-24a103.28 103.28 0 0 0-31.23-73.23m13 93.71a15.89 15.89 0 0 1-15.56 12.4H152a32 32 0 0 0-32 32a16 16 0 0 1-21.31 15.07C62.49 194.3 40 164 40 128a88 88 0 0 1 87.09-88h.9a88.35 88.35 0 0 1 88 87.25a89 89 0 0 1-2.18 20.35ZM140 76a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-44 24a12 12 0 1 1-12-12a12 12 0 0 1 12 12m0 56a12 12 0 1 1-12-12a12 12 0 0 1 12 12m88-56a12 12 0 1 1-12-12a12 12 0 0 1 12 12" />
</g>
</svg>
</span>
<span className={styles.name}>{FILE_NAME}</span>
<span className={styles.cur} data-cur={theme}>
{THEMES.map((t) => (
<span className={styles.curName} data-set={t.set} key={t.set}>
{t.label}
</span>
))}
</span>
</div>
<pre className={styles.code}>
<span className={styles["t--com"]}>/* theme tokens */</span>{"\n"}
<span className={styles["t--k"]}>.card</span>{" {\n --brand: "}
<span className={styles["t--s"]}>"godrich"</span>{";\n box-shadow: "}
<span className={styles["t--n"]}>0 2px 12px</span>{";\n transition: opacity "}
<span className={styles["t--n"]}>220ms</span>{";\n}"}
</pre>
</div>
<div className={styles.opts} role="group" aria-label={GROUP_LABEL}>
{THEMES.map((t) => (
<button
className={styles.opt}
type="button"
data-set={t.set}
aria-pressed={theme === t.set}
key={t.set}
onClick={() => {
setPreview(false);
setTheme(t.set);
}}
>
<span className={styles.ind} aria-hidden="true" />
<span className={styles.dots} aria-hidden="true">
<i className={styles.dot + " " + styles["dot--1"]} />
<i className={styles.dot + " " + styles["dot--2"]} />
<i className={styles.dot + " " + styles["dot--3"]} />
</span>
<span className={styles.lb}>{t.label}</span>
</button>
))}
</div>
</div>
</main>
);
}09 · Editor theme switcher — Measured area 61.065% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Registering the color variables
// Register the color variables — only registered <color> variables cut cleanly with steps(1, end) in keyframes.
// The initial values are the light set (the static default state)
@property --ed-bg { syntax: '<color>'; inherits: true; initial-value: #{$stage-paper}; }
@property --ed-fg { syntax: '<color>'; inherits: true; initial-value: #{$subject-ink}; }
@property --tok-key { syntax: '<color>'; inherits: true; initial-value: #{$subject-blue-deep}; }
@property --tok-str { syntax: '<color>'; inherits: true; initial-value: #7440d4; }
@property --tok-num { syntax: '<color>'; inherits: true; initial-value: #{$error}; }
@property --tok-com { syntax: '<color>'; inherits: true; initial-value: #6b6577; }
All the script does is set data-theme to hc, and the selector swaps six colors plus color-scheme behind it. Measured per text color against the black panel, the high-contrast set reads 21.0:1 for body text, 14.54:1 for keywords, 11.88:1 for strings, 11.37:1 for numbers, and 12.92:1 for comments.
How far does a code editor ui design go without a library?
This is roughly the ceiling. The moment you need several open files, an undo history you control, or a language server answering back, a real document model has to exist, and a purpose-built editor is the honest answer. Below that line — one box that colors, numbers, closes, finds, completes, and complains — two elements carry all of it, and all nine sit on <textarea> plus <pre> with no dependency to install.
Each part is also a standalone code editor ui component, so the gutter can go into a config field on its own and the lint underline can go into a hex input without dragging the other eight along. For a read-only snippet, the frame around the code is a different job, covered in 9 Code Block UI Patterns, and a command-line window is closer to Terminal UI Design. The behavior of the input element itself is documented in MDN's textarea page and setSelectionRange.
Where it breaks — the trap
What the grid plays is a CSS keyframe loop; what your hands do is the JS path. They are separate code paths, so one of them can be dead while the measurement still passes.
Sync code that was never running
02 carried a function that moves the gutter by the text's scrollTop, and for a while it did nothing at all: the textarea was free to grow with its content, so the element that actually scrolled was the box around it. A screen that computes its own coordinates, like the minimap in 05, shares this trap with CSS Chart Design: 9 SVG Graphs, No Library — the drawing is right and the thing being measured is wrong. The password for the nine zip files is 8j2t7h4k, and for exactly this reason, every part was measured twice — once on the static path with .is-demo removed, once through real key events. With the textarea pinned to the height of its grid cell, a 12-line file now measures scrollHeight 256 against clientHeight 116, and scrolling the text to 40 puts both the gutter track and the overlay at matrix(1, 0, 0, 1, 0, -40).
A one-step cut only changes at the end of the step
01 first wrote 0% { opacity: 0 } 100% { opacity: 1 } under steps(1, end). A cut with a single step changes its value at the end of that step, so the layer stayed transparent for the full two seconds and the poster came out as an empty screen: 0.26% moved area and 4/23 frames with motion. Putting a separate jump at 3% took the same clip to 25.07% and 15/23, and 06 had the same shape, which is why its match bands start already lit and the motion is the walk between matches.
Status text written only by JavaScript
04, 05, and 09 write their status line with textContent, and inside the preview loop that code never runs, so whatever the markup shipped with stays on screen. The poster for 04 said the caret was on line 1 while the picture showed lines 3 to 5 held. The fix was two stacked sentences that CSS picks between, and 05 moves continuously, so its preview sentence drops the number entirely.
Three variants and accessibility
Three variables change the character of all nine at once.
| name | changed value | feel |
|---|---|---|
| duration | $dur-loop 2s → 1s |
typing and popups run twice as fast |
| easing | $ease-spring → $ease-pop |
the popup and the bar bounce one more time |
| color | $subject-blue → $stage-yellow |
token accents move from the cold side to the warm |
Every input here is a real <textarea>, so screen readers, IME composition, and native undo keep working without a single patch. The popup in 07 announces itself through role="listbox" and aria-activedescendant while focus stays in the input, and 08 keeps its error count as text rather than as color alone. Under prefers-reduced-motion: reduce, all nine stop the preview loop and the caret blink, and leave the one still frame that reads best for that part. Contrast was measured from rendered pixels across 60 text samples in the nine screens with zero below AA, and the high-contrast theme in 09 was measured separately on its black panel.
FAQ
Is there a code editor ui react version in the files
Yes — the zip ships a react/ folder next to vanilla/, with a .tsx component and a CSS module for each of the nine. The DOM and the event handlers are the same ones shown above, moved into refs and state.
Will Korean or Japanese input break while composing
No, because the real input stays a <textarea> and composition is handled by the browser. A contenteditable rewrite is where composition usually breaks, since the DOM is edited mid-composition; here only the overlay is redrawn, and the input path is never touched. When the code window only has to be read and never edited, 9 Code Block UI Patterns is the lighter answer.
Does the squiggly underline show up in older browsers
caniuse puts text-decoration-style at 96.65% global support (0.03% full plus 96.62% partial), covering Chrome 57+, Edge 79+, Safari 7.1+, and Firefox 6+, checked on 2026-09-13. Internet Explorer and Opera Mini have none of it, which is the other reason the error count is also written out as text.








