01リアルタイム構文ハイライト
文字を透明にした textarea の下に同じ等幅の pre を重ね、入力のたびに 4 本の正規表現で色を付けます。スクロールも二層が一緒に動きます。
使いどころドキュメントサイトのコード入力欄
-webkit-text-fill-colorwhite-space: prescrollTop

<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色</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="コード入力欄">/* card rise */
.card {
--lift: 8px;
content: "rise";
color: #fff7e6;
transition: all 220ms ease;
}</textarea>
</div>
<p class="sh__note">入力すると同じ位置で色が塗り直されます</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 リアルタイム構文ハイライト — 計測 累積面積 25.074% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · html css エディタ UI 9選 — コードエディタ 作り方の基本
このデモが属する記事
html css エディタ UI 9選 — コードエディタ 作り方の基本html css エディタ とは、Web ページの中に置いてコードを打つと色が付き、行番号が並び、括弧が自分で閉じる入力欄のことです。9つともライブラリなし、textarea だけで組んであり、下のグリッドが今すぐ動きます。どの部品もそのま この記事のデモをすべて見る


