03Auto-close brackets
Typing an opening bracket inserts its partner and leaves the caret between them; typing the closing one where it already exists just steps over it.
Best forSnippet input you type fast
setSelectionRangepreventDefaultkey

<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 · Code Editor JavaScript: 9 UI Parts, No Library
The post this demo lives in
Code Editor JavaScript: 9 UI Parts, No LibraryCode editor javascript is the plain way to get coloring, line numbers, and closing brackets into a page: nine parts, no See all demos in this post


