윈도우 98 디자인 9종 — crt 모니터 효과까지 복붙
윈도우 98 디자인은 지금 웹에서도 가장 알아보기 쉬운 레트로 문법입니다. 여기 아홉 개는 그 시절 책상 위 화면을 통째로 옮긴 부품인데, 창을 끌고 최소화까지 진짜로 됩니다.
01 os-window-titlebar-minimize
02 os-crt-scanline-curve
04 os-boot-sequence-lines
06 os-start-menu-accordion
07 os-taskbar-clock-tray
08 os-install-progress-steps
09 os-bluescreen-reboot
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 끌어 옮기는 창과 최소화
- 02 CRT 스캔라인과 곡면 비네트
- 03 픽셀 커서 세 종류
- 04 부팅 점검 줄 출력
- 05 두 번 눌러 여는 바탕화면 아이콘
- 06 시작 메뉴 펼침
- 07 작업표시줄 시계와 알림 칸
- 08 설치 진행 막대와 단계 문구
- 09 파란 오류 화면과 재시작
아홉 개를 이 순서로 늘어놓은 기준은 옛 컴퓨터의 하루 순서입니다. 앞의 셋은 켜기 전의 부품 — 손으로 잡는 창(01), 눈으로 보는 브라운관(02), 화면을 따라다니는 커서(03)입니다. 전원을 누르면 점검 줄이 올라가고(04) 바탕화면의 아이콘(05), 시작 메뉴(06), 작업표시줄 시계(07)가 차례로 살아나며, 설치를 기다리는 시간(08)을 지나 파란 오류 화면과 재시작(09)으로 하루가 닫힙니다. 무대색도 이 흐름을 따라 잉크·옐로·오렌지·페이퍼가 두 바퀴 돌고 잉크로 끝납니다. 손끝 질감을 더 살린 이웃은 실물 스위치 버튼 9종이고, 같은 시대의 화려한 쪽은 Y2K 크롬·홀로그램 랜딩입니다. 04 가 타이핑 커서와 아스키 진행바를 일부러 안 쓴 것은 터미널 UI 9종과 CSS 타이핑 효과 9종과 겹치지 않게 하려는 것이니, 글자가 한 자씩 찍히는 쪽이 필요하면 그 두 편으로 가면 됩니다.
01끌어 옮기는 창과 최소화
타이틀바를 잡으면 창이 손끝을 따라옵니다
타이틀바를 잡아 끌면 setPointerCapture 가 손가락이 창 밖으로 나가도 놓치지 않게 잡아 주고, pointermove 가 올 때마다 translate 로 창을 옮깁니다(MDN Pointer Events). 빗나가는 양은 영역과 창의 폭 차이의 절반까지로 묶어 화면 밖으로 새지 않으니, 창 여러 개를 겹쳐 보여 주는 소개 화면에 얹으면 슬라이드가 아니라 만질 수 있는 판이 됩니다.

<main class="stage">
<div class="dt is-demo" id="dt">
<div class="dt__area" id="area">
<section class="win" id="win" aria-label="예제 창">
<header class="win__bar" id="bar" tabindex="0" aria-label="창 이동 — 잡아 끌거나 방향키로 옮기세요">
<span class="win__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 64v112a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16V64a16 16 0 0 1 16-16h160a16 16 0 0 1 16 16" opacity=".2"/><path d="M208 40H48a24 24 0 0 0-24 24v112a24 24 0 0 0 24 24h160a24 24 0 0 0 24-24V64a24 24 0 0 0-24-24m8 136a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V64a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8Zm-48 48a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8"/></g></svg></span>
<span class="win__title">godrich.exe</span>
<button class="win__min" id="minBtn" type="button" aria-label="최소화"><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="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8"/></g></svg></button>
</header>
<div class="win__panel">
<p class="win__line">타이틀바를 잡아 끌면 창이 따라옵니다</p>
<p class="win__line win__line--dim">최소화하면 작업표시줄 칸으로 접힙니다</p>
</div>
<p class="win__status" aria-hidden="true">준비</p>
</section>
</div>
<nav class="dt__bar" aria-label="작업표시줄">
<button class="dt__slot" id="slot" type="button" aria-label="창 다시 펼치기">
<span class="dt__slotIco" 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="M128 144h80v72l-80-14.55Zm-96 40l64 11.64V144H32Zm96-129.45V112h80V40ZM32 112h64V60.36L32 72Z" opacity=".2"/><path d="M208 136h-80a8 8 0 0 0-8 8v57.45a8 8 0 0 0 6.57 7.88l80 14.54a7.6 7.6 0 0 0 1.43.13a8 8 0 0 0 8-8v-72a8 8 0 0 0-8-8m-8 70.41l-64-11.63V152h64ZM96 136H32a8 8 0 0 0-8 8v40a8 8 0 0 0 6.57 7.87l64 11.64a8.5 8.5 0 0 0 1.43.13a8 8 0 0 0 8-8V144a8 8 0 0 0-8-8m-8 50.05l-48-8.73V152h48ZM213.13 33.86a8 8 0 0 0-6.56-1.73l-80 14.55a8 8 0 0 0-6.57 7.87V112a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8V40a8 8 0 0 0-2.87-6.14M200 104h-64V61.22l64-11.63Zm-98.87-49.78a8 8 0 0 0-6.56-1.73l-64 11.64A8 8 0 0 0 24 72v40a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8V60.36a8 8 0 0 0-2.87-6.14M88 104H40V78.68L88 70Z"/></g></svg></span>
<span class="dt__slotTitle">godrich.exe</span>
</button>
<span class="dt__clock" aria-hidden="true">09:41</span>
</nav>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 끌어 옮기는 창과 최소화 — 타이틀바를 setPointerCapture 로 잡아 pointermove 마다 translate 로 옮기고,
// 최소화하면 창이 작업표시줄 칸 중심으로 scale .12 로 접혔다가 칸을 누르면 원래 자리로 돌아온다.
// 바깥 테두리는 밝은 면과 어두운 면을 두 겹 쌓은 box-shadow 로 튀어나온 느낌을 낸다.
// 격자 미리보기(.is-demo)는 오른쪽 아래로 끌었다가 칸으로 접히고 다시 펴지는 컷을 2초에 한 바퀴 돌린다.
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue-deep;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$bevel-l1: $stage-paper; // 바깥 밝은 면
$bevel-d1: $subject-ink; // 바깥 어두운 면
$bevel-l2: rgba($stage-paper, .6); // 안쪽 밝은 면
$bevel-d2: rgba($subject-ink, .32); // 안쪽 어두운 면
$bevel-raise: inset 1px 1px 0 0 $bevel-l1, inset -1px -1px 0 0 $bevel-d1, inset 2px 2px 0 0 $bevel-l2, inset -2px -2px 0 0 $bevel-d2;
$bevel-btn: inset 1px 1px 0 0 $bevel-l1, inset -1px -1px 0 0 rgba($subject-ink, .5);
$bevel-btn-press: inset 1px 1px 0 0 rgba($subject-ink, .5), inset -1px -1px 0 0 $bevel-l1;
$muted: rgba($subject-ink, .72);
@include stage($stage-ink);
// ── 바탕화면: 창이 다니는 영역(여기서 벗어나면 잘린다)과 작업표시줄
.dt {
display: flex; flex-direction: column; gap: $sp-1;
width: min(100%, 400px); height: 248px;
}
.dt__area {
position: relative; flex: 1 1 auto;
display: grid; place-items: center;
overflow: hidden; // 창이 영역 밖으로 나가면 화면 끝에서 잘린다 — 문서 넘침으로 새지 않는다
}
.dt__bar {
display: flex; align-items: center; gap: $sp-1;
box-sizing: border-box; height: 28px; padding: 0 $sp-1; flex: 0 0 auto;
background: $subject-cream;
box-shadow: $bevel-raise;
}
.dt__slot {
display: inline-flex; align-items: center; gap: $sp-1;
box-sizing: border-box; height: 20px; padding: 0 $sp-2; border: 0;
background: $subject-cream; color: $subject-ink;
font-family: $mono; font-weight: 700; font-size: 11px; line-height: 1.4;
white-space: nowrap; cursor: pointer;
box-shadow: $bevel-btn;
}
.dt__slot:active { box-shadow: $bevel-btn-press; }
// 창이 접혀 있을 때 칸 — 눌린 오목한 면과 파란 배경으로 켜진다
.dt__slot.is-min {
background: $color; color: $subject-cream;
box-shadow: $bevel-btn-press;
}
.dt__slotIco { display: grid; place-items: center; font-size: 12px; }
.dt__slotTitle { white-space: nowrap; }
.dt__clock { margin-left: auto; padding-right: $sp-1; color: $muted; font-weight: 500; font-size: 11px; line-height: 1.4; }
// ── 창 — translate 로만 움직인다(top/left 를 다시 계산하지 않는다)
.win {
position: relative; box-sizing: border-box;
width: min(100%, 272px); height: 120px; padding: $sp-1;
display: flex; flex-direction: column; gap: $sp-1;
background: $subject-cream;
box-shadow: $bevel-raise, $shadow-raised;
}
// 접기/펴기 트랜지션은 이 상태일 때만 — 드래그 중에는 translate 가 즉시 따라와야 한다
.win.is-flying { transition: translate $duration $easing, scale $duration $easing, opacity $duration $easing; }
.win.is-min { opacity: 0; scale: .12; pointer-events: none; }
.win__bar {
display: flex; align-items: center; gap: $sp-1;
height: 24px; padding: 0 $sp-1; flex: 0 0 auto;
background: $color; color: $subject-cream;
cursor: grab; user-select: none; touch-action: none;
}
.win__bar:active { cursor: grabbing; }
.win__bar:focus-visible { outline: 2px solid $stage-paper; outline-offset: -2px; }
.win__ico { display: grid; place-items: center; font-size: 14px; }
.win__title {
flex: 1 1 auto; font-weight: 700; font-size: 12px; line-height: 1.4;
white-space: nowrap; overflow: hidden;
}
.win__min {
display: grid; place-items: center; width: 20px; height: 20px;
box-sizing: border-box; padding: 0; border: 0; flex: 0 0 auto;
background: $subject-cream; color: $subject-ink;
font-size: 12px; line-height: 1; cursor: pointer;
box-shadow: $bevel-btn;
}
.win__min:active { box-shadow: $bevel-btn-press; }
.win__panel {
flex: 1 1 auto; display: flex; flex-direction: column; justify-content: center; gap: $sp-1;
padding: $sp-2; background: $stage-paper;
box-shadow: inset 1px 1px 0 0 rgba($subject-ink, .45), inset -1px -1px 0 0 $stage-paper;
}
.win__line { margin: 0; color: $subject-ink; font-weight: 500; font-size: 11px; line-height: 1.5; }
.win__line--dim { color: $muted; }
.win__status {
margin: 0; flex: 0 0 auto; height: 16px; padding: 0 $sp-2;
display: flex; align-items: center;
background: $stage-paper; color: $muted;
font-weight: 500; font-size: 10px; line-height: 1.4;
box-shadow: inset 1px 1px 0 0 rgba($subject-ink, .35), inset -1px -1px 0 0 $stage-paper;
}
// ── 격자 미리보기 루프 (2초)
// 8% 에 끌기 시작해 30% 까지 오른쪽 아래로 이동, 41% 부터 칸 쪽으로 접혀 51% 에 사라지고,
// 74% 에 다시 펴져 86% 에 제자리로 돌아온다(복귀는 90% 안에 끝난다). 칸 상태는 steps 컷으로 갈아 낀다.
.dt.is-demo .win {
animation-name: winFly;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.dt.is-demo .dt__slot {
animation-name: dtSlotCut;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes winFly {
0%, 8% { translate: 0 0; scale: 1; opacity: 1; }
30%, 41% { translate: 24px 12px; scale: 1; opacity: 1; }
51%, 74% { translate: -104px 64px; scale: .12; opacity: 0; }
86%, 100% { translate: 0 0; scale: 1; opacity: 1; }
}
// 칸의 켜짐 교체 — 배경·글자색이지만 steps(1, end) 컷이라 중간값이 없다(두 번의 이산 전환)
@keyframes dtSlotCut {
0%, 50% {
background: $subject-cream; color: $subject-ink; box-shadow: $bevel-btn;
}
51%, 85% {
background: $color; color: $subject-cream; box-shadow: $bevel-btn-press;
}
86%, 100% {
background: $subject-cream; color: $subject-ink; box-shadow: $bevel-btn;
}
}
// ── 좁은 화면: 창과 표시줄을 줄여 무대 안에 들어온다
@media (max-width: 340px) {
.dt { height: 174px; }
.win { height: 104px; }
.win__bar { height: 20px; }
.win__line { font-size: 10px; }
.win__status { font-size: 9px; }
.dt__slot { height: 18px; font-size: 10px; }
.dt__clock { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.dt.is-demo .win, .dt.is-demo .dt__slot { animation: none !important; }
.win.is-flying { transition: 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);
}
.dt {
display: flex;
flex-direction: column;
gap: 4px;
width: min(100%, 400px);
height: 248px;
}
.dt__area {
position: relative;
flex: 1 1 auto;
display: grid;
place-items: center;
overflow: hidden;
}
.dt__bar {
display: flex;
align-items: center;
gap: 4px;
box-sizing: border-box;
height: 28px;
padding: 0 4px;
flex: 0 0 auto;
background: #fff7e6;
box-shadow: inset 1px 1px 0 0 #ffffff, inset -1px -1px 0 0 #17141a, inset 2px 2px 0 0 rgba(255, 255, 255, 0.6), inset -2px -2px 0 0 rgba(23, 20, 26, 0.32);
}
.dt__slot {
display: inline-flex;
align-items: center;
gap: 4px;
box-sizing: border-box;
height: 20px;
padding: 0 8px;
border: 0;
background: #fff7e6;
color: #17141a;
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;
cursor: pointer;
box-shadow: inset 1px 1px 0 0 #ffffff, inset -1px -1px 0 0 rgba(23, 20, 26, 0.5);
}
.dt__slot:active {
box-shadow: inset 1px 1px 0 0 rgba(23, 20, 26, 0.5), inset -1px -1px 0 0 #ffffff;
}
.dt__slot.is-min {
background: #0a4fe8;
color: #fff7e6;
box-shadow: inset 1px 1px 0 0 rgba(23, 20, 26, 0.5), inset -1px -1px 0 0 #ffffff;
}
.dt__slotIco {
display: grid;
place-items: center;
font-size: 12px;
}
.dt__slotTitle {
white-space: nowrap;
}
.dt__clock {
margin-left: auto;
padding-right: 4px;
color: rgba(23, 20, 26, 0.72);
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.win {
position: relative;
box-sizing: border-box;
width: min(100%, 272px);
height: 120px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 4px;
background: #fff7e6;
box-shadow: inset 1px 1px 0 0 #ffffff, inset -1px -1px 0 0 #17141a, inset 2px 2px 0 0 rgba(255, 255, 255, 0.6), inset -2px -2px 0 0 rgba(23, 20, 26, 0.32), 0 14px 24px rgba(23, 20, 26, 0.32);
}
.win.is-flying {
transition: translate 300ms cubic-bezier(0.2, 0.8, 0.2, 1), scale 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.win.is-min {
opacity: 0;
scale: 0.12;
pointer-events: none;
}
.win__bar {
display: flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 4px;
flex: 0 0 auto;
background: #0a4fe8;
color: #fff7e6;
cursor: grab;
user-select: none;
touch-action: none;
}
.win__bar:active {
cursor: grabbing;
}
.win__bar:focus-visible {
outline: 2px solid #ffffff;
outline-offset: -2px;
}
.win__ico {
display: grid;
place-items: center;
font-size: 14px;
}
.win__title {
flex: 1 1 auto;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
}
.win__min {
display: grid;
place-items: center;
width: 20px;
height: 20px;
box-sizing: border-box;
padding: 0;
border: 0;
flex: 0 0 auto;
background: #fff7e6;
color: #17141a;
font-size: 12px;
line-height: 1;
cursor: pointer;
box-shadow: inset 1px 1px 0 0 #ffffff, inset -1px -1px 0 0 rgba(23, 20, 26, 0.5);
}
.win__min:active {
box-shadow: inset 1px 1px 0 0 rgba(23, 20, 26, 0.5), inset -1px -1px 0 0 #ffffff;
}
.win__panel {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
padding: 8px;
background: #ffffff;
box-shadow: inset 1px 1px 0 0 rgba(23, 20, 26, 0.45), inset -1px -1px 0 0 #ffffff;
}
.win__line {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.5;
}
.win__line--dim {
color: rgba(23, 20, 26, 0.72);
}
.win__status {
margin: 0;
flex: 0 0 auto;
height: 16px;
padding: 0 8px;
display: flex;
align-items: center;
background: #ffffff;
color: rgba(23, 20, 26, 0.72);
font-weight: 500;
font-size: 10px;
line-height: 1.4;
box-shadow: inset 1px 1px 0 0 rgba(23, 20, 26, 0.35), inset -1px -1px 0 0 #ffffff;
}
.dt.is-demo .win {
animation-name: winFly;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.dt.is-demo .dt__slot {
animation-name: dtSlotCut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes winFly {
0%, 8% {
translate: 0 0;
scale: 1;
opacity: 1;
}
30%, 41% {
translate: 24px 12px;
scale: 1;
opacity: 1;
}
51%, 74% {
translate: -104px 64px;
scale: 0.12;
opacity: 0;
}
86%, 100% {
translate: 0 0;
scale: 1;
opacity: 1;
}
}
@keyframes dtSlotCut {
0%, 50% {
background: #fff7e6;
color: #17141a;
box-shadow: inset 1px 1px 0 0 #ffffff, inset -1px -1px 0 0 rgba(23, 20, 26, 0.5);
}
51%, 85% {
background: #0a4fe8;
color: #fff7e6;
box-shadow: inset 1px 1px 0 0 rgba(23, 20, 26, 0.5), inset -1px -1px 0 0 #ffffff;
}
86%, 100% {
background: #fff7e6;
color: #17141a;
box-shadow: inset 1px 1px 0 0 #ffffff, inset -1px -1px 0 0 rgba(23, 20, 26, 0.5);
}
}
@media (max-width: 340px) {
.dt {
height: 174px;
}
.win {
height: 104px;
}
.win__bar {
height: 20px;
}
.win__line {
font-size: 10px;
}
.win__status {
font-size: 9px;
}
.dt__slot {
height: 18px;
font-size: 10px;
}
.dt__clock {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.dt.is-demo .win, .dt.is-demo .dt__slot {
animation: none !important;
}
.win.is-flying {
transition: none !important;
}
}var dt = document.getElementById('dt');
var area = document.getElementById('area');
var win = document.getElementById('win');
var bar = document.getElementById('bar');
var minBtn = document.getElementById('minBtn');
var slot = document.getElementById('slot');
var px = 0, py = 0, savedX = 0, savedY = 0, min = false;
function place() { win.style.translate = px + 'px ' + py + 'px'; }
function clampNum(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function dragBounds() {
return {
x: Math.max(0, (area.clientWidth - win.offsetWidth) / 2),
y: Math.max(0, (area.clientHeight - win.offsetHeight) / 2)
};
}
// 접기/펴기 — 접힐 때 칸 중심으로 translate 를 옮기고 scale 을 줄인다. 원래 자리는 saved 에 둔다
function setMin(on) {
min = on;
win.classList.toggle('is-min', on);
slot.classList.toggle('is-min', on);
win.classList.add('is-flying');
if (on) {
savedX = px; savedY = py;
var wr = win.getBoundingClientRect(), sr = slot.getBoundingClientRect();
px += (sr.left + sr.width / 2) - (wr.left + wr.width / 2);
py += (sr.top + sr.height / 2) - (wr.top + wr.height / 2);
} else {
px = savedX; py = savedY;
}
place();
window.setTimeout(function () { win.classList.remove('is-flying'); }, 320);
}
dt.addEventListener('pointerdown', function () { dt.classList.remove('is-demo'); });
bar.addEventListener('pointerdown', function (e) {
if (min || e.target.closest('.win__min')) return;
e.preventDefault();
bar.setPointerCapture(e.pointerId);
var ox = px - e.clientX, oy = py - e.clientY;
function mv(ev) {
var b = dragBounds();
px = clampNum(ev.clientX + ox, -b.x, b.x);
py = clampNum(ev.clientY + oy, -b.y, b.y);
place();
}
bar.addEventListener('pointermove', mv);
bar.addEventListener('pointerup', function () { bar.removeEventListener('pointermove', mv); }, { once: true });
bar.addEventListener('pointercancel', function () { bar.removeEventListener('pointermove', mv); }, { once: true });
});
bar.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { dt.classList.remove('is-demo'); setMin(true); return; }
if (min || e.key.indexOf('Arrow') !== 0) return;
e.preventDefault();
dt.classList.remove('is-demo');
var b = dragBounds(), d = e.shiftKey ? 16 : 4;
if (e.key === 'ArrowLeft') px = clampNum(px - d, -b.x, b.x);
if (e.key === 'ArrowRight') px = clampNum(px + d, -b.x, b.x);
if (e.key === 'ArrowUp') py = clampNum(py - d, -b.y, b.y);
if (e.key === 'ArrowDown') py = clampNum(py + d, -b.y, b.y);
place();
});
minBtn.addEventListener('click', function () { dt.classList.remove('is-demo'); setMin(true); });
slot.addEventListener('click', function () { dt.classList.remove('is-demo'); setMin(false); });// RetroWindow — vanilla 판(run/023/demo/01-os-window-titlebar-minimize/index.html) 의 React 포트.
// 타이틀바를 setPointerCapture 로 잡아 pointermove 마다 translate 로 옮기고, 최소화하면 창이
// 작업표시줄 칸 중심으로 scale .12 로 접혔다가 칸을 누르면 원래 자리로 돌아온다.
// 이동량은 ref 하나가 들고 있고 상태는 미리보기·접힘 두 개뿐이다(드래그마다 재렌더하지 않는다).
import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RetroWindow.module.scss";
export type RetroWindowProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 타입에 React 이벤트 제네릭이 없다 — 쓰는 것만 로컬 타입으로 선언한다
type PtrEvt = {
pointerId: number;
clientX: number;
clientY: number;
target: { closest(sel: string): Element | null };
preventDefault(): void;
};
type KeyEvt = { key: string; shiftKey?: boolean; preventDefault(): void };
// 초기값을 준 useRef 는 실제로 null 이 되지 않는다 — 값 상자(Box)로 좁혀 쓴다
type XY = { x: number; y: number };
type Box<T> = { current: T };
const TITLE = "godrich.exe";
const STATUS = "준비";
const CLOCK = "09:41";
const LINE1 = "타이틀바를 잡아 끌면 창이 따라옵니다";
const LINE2 = "최소화하면 작업표시줄 칸으로 접힙니다";
const ARIA_WIN = "예제 창";
const ARIA_BAR = "창 이동 — 잡아 끌거나 방향키로 옮기세요";
const ARIA_MIN = "최소화";
const ARIA_SLOT = "창 다시 펼치기";
const ARIA_TASKBAR = "작업표시줄";
const ICON_WIN = (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 64v112a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16V64a16 16 0 0 1 16-16h160a16 16 0 0 1 16 16" opacity=".2"/><path d="M208 40H48a24 24 0 0 0-24 24v112a24 24 0 0 0 24 24h160a24 24 0 0 0 24-24V64a24 24 0 0 0-24-24m8 136a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V64a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8Zm-48 48a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8"/></g></svg>
);
const ICON_MIN = (
<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="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8"/></g></svg>
);
const ICON_LOGO = (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M128 144h80v72l-80-14.55Zm-96 40l64 11.64V144H32Zm96-129.45V112h80V40ZM32 112h64V60.36L32 72Z" opacity=".2"/><path d="M208 136h-80a8 8 0 0 0-8 8v57.45a8 8 0 0 0 6.57 7.88l80 14.54a7.6 7.6 0 0 0 1.43.13a8 8 0 0 0 8-8v-72a8 8 0 0 0-8-8m-8 70.41l-64-11.63V152h64ZM96 136H32a8 8 0 0 0-8 8v40a8 8 0 0 0 6.57 7.87l64 11.64a8.5 8.5 0 0 0 1.43.13a8 8 0 0 0 8-8V144a8 8 0 0 0-8-8m-8 50.05l-48-8.73V152h48ZM213.13 33.86a8 8 0 0 0-6.56-1.73l-80 14.55a8 8 0 0 0-6.57 7.87V112a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8V40a8 8 0 0 0-2.87-6.14M200 104h-64V61.22l64-11.63Zm-98.87-49.78a8 8 0 0 0-6.56-1.73l-64 11.64A8 8 0 0 0 24 72v40a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8V60.36a8 8 0 0 0-2.87-6.14M88 104H40V78.68L88 70Z"/></g></svg>
);
function clampNum(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
export default function RetroWindow(props: RetroWindowProps) {
const duration = props.duration ?? 300;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#0a4fe8";
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const [preview, setPreview] = useState<boolean>(true);
const [min, setMin] = useState<boolean>(false);
const [flying, setFlying] = useState<boolean>(false);
const pos = useRef<XY>({ x: 0, y: 0 }) as Box<XY>;
const saved = useRef<XY>({ x: 0, y: 0 }) as Box<XY>;
const areaRef = useRef<HTMLDivElement>(null);
const winRef = useRef<HTMLElement>(null);
const barRef = useRef<HTMLElement>(null);
const slotRef = useRef<HTMLButtonElement>(null);
const flyTimer = useRef<number | null>(null);
const place = () => {
const win = winRef.current;
if (win) win.style.translate = pos.current.x + "px " + pos.current.y + "px";
};
const dragBounds = () => {
const area = areaRef.current;
const win = winRef.current;
if (!area || !win) return { x: 0, y: 0 };
return {
x: Math.max(0, (area.clientWidth - win.offsetWidth) / 2),
y: Math.max(0, (area.clientHeight - win.offsetHeight) / 2)
};
};
// 접기/펴기 — 접힐 때 칸 중심으로 translate 를 옮기고 scale 을 줄인다. 원래 자리는 saved 에 둔다
const setMinState = (on: boolean) => {
const win = winRef.current;
const slot = slotRef.current;
setMin(on);
setFlying(true);
if (on) {
saved.current = { ...pos.current };
if (win && slot) {
const wr = win.getBoundingClientRect();
const sr = slot.getBoundingClientRect();
pos.current.x += sr.left + sr.width / 2 - (wr.left + wr.width / 2);
pos.current.y += sr.top + sr.height / 2 - (wr.top + wr.height / 2);
}
} else {
pos.current = { ...saved.current };
}
place();
if (flyTimer.current !== null) window.clearTimeout(flyTimer.current);
flyTimer.current = window.setTimeout(() => setFlying(false), duration + 20);
};
const onBarDown = (e: PtrEvt) => {
setPreview(false);
if (min || e.target.closest("." + (styles.winMin || "winMin"))) return;
e.preventDefault();
const bar = barRef.current;
if (!bar) return;
bar.setPointerCapture(e.pointerId);
const ox = pos.current.x - e.clientX;
const oy = pos.current.y - e.clientY;
// 여기서부터는 React 합성 이벤트가 아니라 DOM 이 직접 주는 PointerEvent 다
const mv = (ev: PointerEvent) => {
const b = dragBounds();
pos.current.x = clampNum(ev.clientX + ox, -b.x, b.x);
pos.current.y = clampNum(ev.clientY + oy, -b.y, b.y);
place();
};
bar.addEventListener("pointermove", mv);
bar.addEventListener(
"pointerup",
() => bar.removeEventListener("pointermove", mv),
{ once: true } as AddEventListenerOptions
);
bar.addEventListener(
"pointercancel",
() => bar.removeEventListener("pointermove", mv),
{ once: true } as AddEventListenerOptions
);
};
const onBarKey = (e: KeyEvt) => {
if (e.key === "Escape") {
setPreview(false);
setMinState(true);
return;
}
if (min || e.key.indexOf("Arrow") !== 0) return;
e.preventDefault();
setPreview(false);
const b = dragBounds();
const d = e.shiftKey ? 16 : 4;
if (e.key === "ArrowLeft") pos.current.x = clampNum(pos.current.x - d, -b.x, b.x);
if (e.key === "ArrowRight") pos.current.x = clampNum(pos.current.x + d, -b.x, b.x);
if (e.key === "ArrowUp") pos.current.y = clampNum(pos.current.y - d, -b.y, b.y);
if (e.key === "ArrowDown") pos.current.y = clampNum(pos.current.y + d, -b.y, b.y);
place();
};
return (
<main className={styles.stage}>
<div
className={styles.dt + (preview ? " " + styles.isDemo : "")}
style={vars}
onPointerDown={() => setPreview(false)}
>
<div className={styles.area} ref={areaRef}>
<section
className={
styles.win + (min ? " " + styles.isMin : "") + (flying ? " " + styles.isFlying : "")
}
ref={winRef}
aria-label={ARIA_WIN}
>
<header
className={styles.bar}
ref={barRef}
tabIndex={0}
aria-label={ARIA_BAR}
onPointerDown={onBarDown}
onKeyDown={onBarKey}
>
<span className={styles.ico} aria-hidden="true">{ICON_WIN}</span>
<span className={styles.title}>{TITLE}</span>
<button
className={styles.winMin}
type="button"
aria-label={ARIA_MIN}
onClick={() => {
setPreview(false);
setMinState(true);
}}
>
{ICON_MIN}
</button>
</header>
<div className={styles.panel}>
<p className={styles.line}>{LINE1}</p>
<p className={styles.lineDim}>{LINE2}</p>
</div>
<p className={styles.status} aria-hidden="true">{STATUS}</p>
</section>
</div>
<nav className={styles.taskbar} aria-label={ARIA_TASKBAR}>
<button
className={styles.slot + (min ? " " + styles.slotOn : "")}
ref={slotRef}
type="button"
aria-label={ARIA_SLOT}
onClick={() => {
setPreview(false);
setMinState(false);
}}
>
<span className={styles.slotIco} aria-hidden="true">{ICON_LOGO}</span>
<span className={styles.slotTitle}>{TITLE}</span>
</button>
<span className={styles.clock} aria-hidden="true">{CLOCK}</span>
</nav>
</div>
</main>
);
}01 · 끌어 옮기는 창과 최소화 — 측정 누적면적 28.026% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 접기/펴기 — 접힐 때 칸 중심으로 translate 를 옮기고 scale 을 줄인다. 원래 자리는 saved 에 둔다
function setMin(on) {
min = on;
win.classList.toggle('is-min', on);
slot.classList.toggle('is-min', on);
win.classList.add('is-flying');
if (on) {
savedX = px; savedY = py;
var wr = win.getBoundingClientRect(), sr = slot.getBoundingClientRect();
px += (sr.left + sr.width / 2) - (wr.left + wr.width / 2);
py += (sr.top + sr.height / 2) - (wr.top + wr.height / 2);
} else {
px = savedX; py = savedY;
}
place();
window.setTimeout(function () { win.classList.remove('is-flying'); }, 320);
}
접힐 때 칸 중심을 다시 계산하는 까닭
최소화를 누르면 창 중심과 작업표시줄 칸 중심의 차이를 그 순간 다시 계산해 그 칸으로 scale .12 로 빨려 들어가듯 접히고, 칸을 누르면 저장해 둔 자리로 돌아오며, 테두리는 밝은 면과 어두운 면을 네 겹 겹친 box-shadow 로 튀어나와 보입니다.
02CRT 스캔라인과 곡면 비네트
줄무늬 타일 하나와 타원 그림자 한 장
가로 줄무늬는 repeating-linear-gradient 타일 하나(2px 줄 + 2px 건너뜀)가 전부이고 background-size 100% 4px 로 화면 전체에 반복됩니다. 그 위에 가장자리로 갈수록 어두워지는 타원 radial-gradient 를 얹어 브라운관처럼 굽은 유리를 흉내 내고, 밝은 띠 하나가 위에서 아래로 흐르는데 시작과 끝이 모두 화면 밖이라 루프가 닫히는 이음새가 보이지 않습니다.

<main class="stage">
<figure class="crt">
<div class="crt__bezel">
<div class="crt__screen">
<div class="crt__content">
<p class="crt__title">레트로 OS</p>
<p class="crt__sub">crt 모니터 효과 — css 만으로</p>
</div>
<span class="crt__band" aria-hidden="true"></span>
<span class="crt__lines" aria-hidden="true"></span>
<span class="crt__vig" aria-hidden="true"></span>
</div>
</div>
<figcaption class="crt__cap">줄무늬 타일 하나와 타원 비네트로 브라운관을 흉내 냈습니다</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// CRT 스캔라인과 곡면 비네트 — 가로 줄무늬를 타일 하나(4px, 그 안 줄 위치 0 부터 2px)로 정의해
// background-size 로 반복시키고, radial-gradient 타원으로 가장자리로 갈수록 어두워지는 유리를 얹는다.
// 밝은 띠 하나가 translateY 로 위에서 아래로 흐른다. 화면 안쪽 바탕은 비토큰 배경 픽셀(짙은 남색)이다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$glass: #0d1220; // 비토큰 배경 픽셀 — 브라운관 형광면의 짙은 남색 바탕(주변 토큰으로는 없는 색)
@include stage($stage-yellow);
.crt {
display: flex; flex-direction: column; gap: $sp-2;
width: min(100%, 340px); margin: 0;
}
// ── 모니터 껍데기
.crt__bezel {
box-sizing: border-box; padding: $sp-3;
background: $subject-ink; border-radius: $r-card;
box-shadow: $shadow-raised;
}
// ── 유리면 — 안의 도형은 전부 이 상자에 absolute 로 겹친다. 밖으로 나가면 잘린다
.crt__screen {
position: relative; overflow: hidden;
display: grid; place-items: center;
height: clamp(112px, 58vh, 196px);
background: $glass;
border-radius: $r-sm;
}
.crt__content {
position: relative; z-index: 1;
display: grid; gap: $sp-1; justify-items: center;
padding: $sp-2; text-align: center;
}
.crt__title {
margin: 0; color: $color;
font-family: $mono; font-weight: 700;
font-size: clamp(16px, 4.2vh, 22px); line-height: 1.2;
letter-spacing: .08em;
text-shadow: 0 0 12px rgba($subject-cream, .35); // 형광체 빛 번짐
}
.crt__sub {
margin: 0; color: rgba($subject-cream, .82);
font-family: $mono; font-weight: 500;
font-size: clamp(10px, 2.6vh, 12px); line-height: 1.4;
letter-spacing: .04em;
}
// ── 위에서 아래로 흐르는 밝은 띠 — transform 만 움직인다
.crt__band {
position: absolute; left: 0; right: 0; top: 0; z-index: 2;
height: 30%; pointer-events: none;
background: linear-gradient(to bottom,
transparent,
rgba($subject-cream, .08) 28%,
rgba($subject-cream, .14) 50%,
rgba($subject-cream, .08) 72%,
transparent);
animation-name: crtBand;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
// ── 스캔라인 — 타일(4px)을 background-size 로 정의하고 그 안에 줄 위치를 둔다
.crt__lines {
position: absolute; inset: 0; z-index: 3; pointer-events: none;
background-image: repeating-linear-gradient(to bottom,
rgba($subject-ink, .38) 0 2px,
transparent 2px 4px);
background-size: 100% 4px;
}
// ── 곡면 비네트 — 타원으로 가장자리가 감아 들어가는 유리
.crt__vig {
position: absolute; inset: 0; z-index: 4; pointer-events: none;
border-radius: inherit;
background: radial-gradient(ellipse 130% 105% at 50% 46%,
transparent 40%,
rgba($subject-ink, .18) 64%,
rgba($subject-ink, .55) 100%);
}
.crt__cap {
margin: 0; color: $subject-ink;
font-weight: 500; font-size: 11px; line-height: 1.4;
text-align: center;
}
// 띠는 화면 높이의 넉 배 남짓을 지나가므로 시작과 끝이 모두 화면 밖 — 루프 이음새가 보이지 않는다
@keyframes crtBand {
0% { transform: translateY(-110%); }
100% { transform: translateY(500%); }
}
// ── 좁은 화면: 껍데기와 캡션을 줄인다(화면 높이는 vh 기준이라 함께 줄어든다)
@media (max-width: 340px) {
.crt { gap: $sp-1; }
.crt__bezel { padding: $sp-2; }
.crt__cap { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.crt__band { animation: none !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);
}
.crt {
display: flex;
flex-direction: column;
gap: 8px;
width: min(100%, 340px);
margin: 0;
}
.crt__bezel {
box-sizing: border-box;
padding: 12px;
background: #17141a;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.crt__screen {
position: relative;
overflow: hidden;
display: grid;
place-items: center;
height: clamp(112px, 58vh, 196px);
background: #0d1220;
border-radius: 8px;
}
.crt__content {
position: relative;
z-index: 1;
display: grid;
gap: 4px;
justify-items: center;
padding: 8px;
text-align: center;
}
.crt__title {
margin: 0;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: clamp(16px, 4.2vh, 22px);
line-height: 1.2;
letter-spacing: 0.08em;
text-shadow: 0 0 12px rgba(255, 247, 230, 0.35);
}
.crt__sub {
margin: 0;
color: rgba(255, 247, 230, 0.82);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: clamp(10px, 2.6vh, 12px);
line-height: 1.4;
letter-spacing: 0.04em;
}
.crt__band {
position: absolute;
left: 0;
right: 0;
top: 0;
z-index: 2;
height: 30%;
pointer-events: none;
background: linear-gradient(to bottom, transparent, rgba(255, 247, 230, 0.08) 28%, rgba(255, 247, 230, 0.14) 50%, rgba(255, 247, 230, 0.08) 72%, transparent);
animation-name: crtBand;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.crt__lines {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
background-image: repeating-linear-gradient(to bottom, rgba(23, 20, 26, 0.38) 0 2px, transparent 2px 4px);
background-size: 100% 4px;
}
.crt__vig {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
border-radius: inherit;
background: radial-gradient(ellipse 130% 105% at 50% 46%, transparent 40%, rgba(23, 20, 26, 0.18) 64%, rgba(23, 20, 26, 0.55) 100%);
}
.crt__cap {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
text-align: center;
}
@keyframes crtBand {
0% {
transform: translateY(-110%);
}
100% {
transform: translateY(500%);
}
}
@media (max-width: 340px) {
.crt {
gap: 4px;
}
.crt__bezel {
padding: 8px;
}
.crt__cap {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.crt__band {
animation: none !important;
}
}// CrtScreen — vanilla 판(run/023/demo/02-os-crt-scanline-curve/index.html) 의 React 포트.
// 가로 줄무늬를 타일 하나(4px)로 정의해 background-size 로 반복하고 radial-gradient 타원 비네트를
// 얹는다. 밝은 띠가 translateY 로 위에서 아래로 흐른다. 조작 상태가 없어 그림만 그린다.
import type { CSSProperties } from "react";
import styles from "./CrtScreen.module.scss";
export type CrtScreenProps = {
duration?: number;
easing?: string;
color?: string;
title?: string;
subtitle?: string;
caption?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CrtScreen(props: CrtScreenProps) {
const vars: Vars = {
"--duration": (props.duration ?? 2000) + "ms",
"--easing": props.easing ?? "ease-out",
"--color": props.color ?? "#fff7e6"
};
return (
<main className={styles.stage}>
<figure className={styles.crt} style={vars}>
<div className={styles.bezel}>
<div className={styles.screen}>
<div className={styles.content}>
<p className={styles.title}>{props.title ?? "레트로 OS"}</p>
<p className={styles.sub}>{props.subtitle ?? "crt 모니터 효과 — css 만으로"}</p>
</div>
<span className={styles.band} aria-hidden="true" />
<span className={styles.lines} aria-hidden="true" />
<span className={styles.vig} aria-hidden="true" />
</div>
</div>
<figcaption className={styles.cap}>
{props.caption ?? "줄무늬 타일 하나와 타원 비네트로 브라운관을 흉내 냈습니다"}
</figcaption>
</figure>
</main>
);
}02 · CRT 스캔라인과 곡면 비네트 — 측정 누적면적 37.75% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// ── 스캔라인 — 타일(4px)을 background-size 로 정의하고 그 안에 줄 위치를 둔다
.crt__lines {
position: absolute; inset: 0; z-index: 3; pointer-events: none;
background-image: repeating-linear-gradient(to bottom,
rgba($subject-ink, .38) 0 2px,
transparent 2px 4px);
background-size: 100% 4px;
}
// ── 곡면 비네트 — 타원으로 가장자리가 감아 들어가는 유리
.crt__vig {
position: absolute; inset: 0; z-index: 4; pointer-events: none;
border-radius: inherit;
background: radial-gradient(ellipse 130% 105% at 50% 46%,
transparent 40%,
rgba($subject-ink, .18) 64%,
rgba($subject-ink, .55) 100%);
}
가장 넓게 움직이지만 가장 조용한 판
측정값은 누적면적 37.75% 로 아홉 개 중 가장 넓지만 강도 평균 16.3 으로 가장 조용해서, 레트로 톤을 깔아야 하는 히어로 배경으로 써도 앞에 놓은 글자를 침범하지 않습니다.
03픽셀 커서 세 종류
box-shadow 로 그린 픽셀 도화
화살표·모래시계·손가락 세 커서가 체커보드 격자 위에서 steps(1, end) 컷으로 차례로 바뀌며, 모래시계는 제자리에서 180도 뒤집힙니다. 그림은 pxRuns 함수가 가로줄 좌표(x y 폭)를 box-shadow 그림자로 펼친 픽셀 도화인데, 확대를 정수배(scale 6)로만 하고 image-rendering 를 pixelated 로 심어 두어 픽셀 마우스 커서가 커져도 경계가 흐려지지 않습니다.
<main class="stage">
<div class="pxc is-demo" id="pxc">
<div class="pxc__slot" id="slot" data-cur="arrow" aria-label="커서 미리보기">
<span class="px pxc__cur pxc__cur--arrow" aria-hidden="true"></span>
<span class="px pxc__cur pxc__cur--hour" aria-hidden="true"></span>
<span class="px pxc__cur pxc__cur--hand" aria-hidden="true"></span>
<span class="pxc__grid" aria-hidden="true"></span>
</div>
<div class="pxc__row" role="group" aria-label="커서 고르기">
<button class="pxc__pick is-on" type="button" data-set="arrow" aria-pressed="true">
<span class="px pxc__mini pxc__mini--arrow" aria-hidden="true"></span>
<span class="pxc__lbl">화살표</span>
</button>
<button class="pxc__pick" type="button" data-set="hour" aria-pressed="false">
<span class="px pxc__mini pxc__mini--hour" aria-hidden="true"></span>
<span class="pxc__lbl">모래시계</span>
</button>
<button class="pxc__pick" type="button" data-set="hand" aria-pressed="false">
<span class="px pxc__mini pxc__mini--hand" aria-hidden="true"></span>
<span class="pxc__lbl">손가락</span>
</button>
</div>
</div>
</main>@use "sass:list";
@use "../../../../site/static/demo-tokens" as *;
// 픽셀 커서 세 종류 — 화살표·모래시계·손가락을 1px 칸의 격자 그림(box-shadow 픽셀)으로 그리고
// scale 로 정수배만 확대해 픽셀 경계를 그대로 지킨다. 칸의 격자 무늬는 conic-gradient 체커보드다.
// 미리보기(.is-demo)에서는 세 커서가 steps(1, end) 컷으로 차례로 바뀌고 모래시계는 제자리에서 뒤집힌다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $stage-yellow;
// ── 픽셀 그림 — 가로 줄 runs (x y 폭). K 는 검은 외곽선, W 는 흰 채움, S 는 모래
@function pxRuns($runs, $c) {
$s: ();
@each $r in $runs {
@for $i from 0 through list.nth($r, 3) - 1 {
$s: list.append($s, ((list.nth($r, 1) + $i) * 1px) (list.nth($r, 2) * 1px) 0 0 $c, comma);
}
}
@return $s;
}
// 화살표 11x16
$arrow-k: (0 0 1), (0 1 2), (0 2 1), (2 2 1), (0 3 1), (3 3 1), (0 4 1), (4 4 1), (0 5 1), (5 5 1),
(0 6 1), (6 6 1), (0 7 1), (7 7 1), (0 8 1), (8 8 1), (0 9 1), (9 9 1),
(0 10 1), (6 10 5), (0 11 1), (3 11 1), (6 11 1), (0 12 1), (2 12 1), (4 12 1), (7 12 1),
(0 13 2), (4 13 1), (7 13 1), (0 14 1), (5 14 1), (8 14 1), (5 15 2);
$arrow-w: (1 2 1), (1 3 2), (1 4 3), (1 5 4), (1 6 5), (1 7 6), (1 8 7), (1 9 8), (1 10 5),
(1 11 2), (4 11 2), (1 12 1), (5 12 2), (5 13 2), (6 14 2);
// 모래시계 9x11
$hour-k: (0 0 9), (0 1 1), (8 1 1), (0 2 1), (8 2 1), (0 3 1), (8 3 1), (0 4 1), (8 4 1),
(0 5 1), (8 5 1), (0 6 1), (8 6 1), (0 7 1), (8 7 1), (0 8 1), (8 8 1), (0 9 1), (8 9 1), (0 10 9);
$hour-s: (1 1 7), (2 2 5), (3 3 3), (4 4 1), (4 5 1), (3 6 1), (5 6 1),
(2 7 1), (4 7 1), (6 7 1), (2 8 5), (1 9 7);
// 손가락 8x13
$hand-k: (3 0 2), (2 1 1), (5 1 1), (2 2 1), (5 2 1), (2 3 1), (5 3 1), (2 4 1), (5 4 1),
(1 5 2), (5 5 2), (0 6 1), (7 6 1), (0 7 1), (7 7 1), (0 8 1), (7 8 1), (0 9 1), (7 9 1),
(0 10 1), (7 10 1), (1 11 1), (6 11 1), (1 12 6);
$hand-w: (3 1 2), (3 2 2), (3 3 2), (3 4 2), (3 5 2), (1 6 6), (1 7 6), (1 8 6), (1 9 6),
(1 10 6), (2 11 4);
$arrow-art: list.join(pxRuns($arrow-k, $subject-ink), pxRuns($arrow-w, $stage-paper));
$hour-art: list.join(pxRuns($hour-k, $subject-ink), pxRuns($hour-s, $color));
$hand-art: list.join(pxRuns($hand-k, $subject-ink), pxRuns($hand-w, $stage-paper));
@include stage($stage-orange);
.pxc {
display: flex; flex-direction: column; gap: $sp-2;
width: min(100%, 200px);
}
// ── 픽셀 그림 바탕 — 1px 칸 하나. 픽셀 그림 요소에는 image-rendering: pixelated 를 기본으로 심는다
// (그림을 배경 이미지·캔버스로 바꿔 확대해도 보간으로 흐려지지 않는다)
.px {
display: block; width: 1px; height: 1px;
image-rendering: pixelated;
}
// ── 큰 미리보기 칸 — conic-gradient 체커보드 격자 위에 세 커서를 한 칸에 겹쳐 둔다
.pxc__slot {
position: relative;
display: grid; place-items: center;
height: 124px;
background: $subject-cream; border-radius: $r-sm;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .18);
}
.pxc__slot > .px { grid-area: 1 / 1; }
.pxc__grid {
position: absolute; inset: 0; pointer-events: none;
background-image: conic-gradient(
rgba($subject-ink, .08) 25%,
transparent 0 50%,
rgba($subject-ink, .08) 0 75%,
transparent 0);
background-size: 12px 12px; // 격자 한 칸 12px = 커서 픽셀(6px) 두 칸
}
.pxc__cur { scale: 6; opacity: 0; }
.pxc__cur--arrow { box-shadow: $arrow-art; }
.pxc__cur--hour { box-shadow: $hour-art; }
.pxc__cur--hand { box-shadow: $hand-art; }
// 정적 경로(사람이 고른 커서) — data-cur 값이 곧 보이는 커서다
.pxc__slot[data-cur="arrow"] .pxc__cur--arrow { opacity: 1; }
.pxc__slot[data-cur="hour"] .pxc__cur--hour { opacity: 1; }
.pxc__slot[data-cur="hand"] .pxc__cur--hand { opacity: 1; }
// ── 고르기 단추 — 실제 크기(2배) 커서와 이름
.pxc__row { display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-2; }
.pxc__pick {
display: grid; justify-items: center; align-content: center; gap: $sp-1;
box-sizing: border-box; height: 64px; padding: $sp-1; border: 0;
background: $subject-cream; color: $subject-ink; border-radius: $r-xs;
font-family: inherit; font-weight: 700; font-size: 10px; line-height: 1.4;
cursor: pointer;
}
.pxc__pick.is-on { box-shadow: inset 0 0 0 2px $subject-ink; }
.pxc__mini { scale: 2; }
.pxc__mini--arrow { box-shadow: $arrow-art; }
.pxc__mini--hour { box-shadow: $hour-art; }
.pxc__mini--hand { box-shadow: $hand-art; }
// ── 격자 미리보기 루프 (2초)
// 0-29% 화살표(마우스를 옮기듯 작게 배회), 30-62% 모래시계(제자리에서 180 도 뒤집힘),
// 63-88% 손가락(살짝 흔들림). 교체는 전부 steps(1, end) 컷이고 89% 안에 화살표로 돌아온다.
.pxc.is-demo .pxc__cur {
animation-duration: $duration;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pxc.is-demo .pxc__cur--arrow {
animation-name: pxcArrowO, pxcArrowM;
animation-timing-function: steps(1, end), $easing;
}
.pxc.is-demo .pxc__cur--hour {
animation-name: pxcHourO, pxcHourR;
animation-timing-function: steps(1, end), linear;
}
.pxc.is-demo .pxc__cur--hand {
animation-name: pxcHandO, pxcHandM;
animation-timing-function: steps(1, end), $easing;
}
@keyframes pxcArrowO {
0%, 29% { opacity: 1; }
30%, 88% { opacity: 0; }
89%, 100% { opacity: 1; }
}
@keyframes pxcArrowM {
0% { translate: 0 0; }
10% { translate: 3px 2px; }
20% { translate: -2px 4px; }
29%, 100% { translate: 0 0; }
}
@keyframes pxcHourO {
0%, 29% { opacity: 0; }
30%, 62% { opacity: 1; }
63%, 100% { opacity: 0; }
}
@keyframes pxcHourR {
0%, 30% { rotate: 0deg; }
62%, 100% { rotate: 180deg; }
}
@keyframes pxcHandO {
0%, 62% { opacity: 0; }
63%, 88% { opacity: 1; }
89%, 100% { opacity: 0; }
}
@keyframes pxcHandM {
0%, 63% { translate: 0 0; }
72% { translate: 2px -2px; }
80% { translate: -2px 0; }
88%, 100% { translate: 0 0; }
}
// ── 좁은 화면: 큰 칸과 그림 배율을 줄인다(정수 배율만 — 픽셀 경계 유지)
@media (max-width: 340px) {
.pxc { gap: $sp-1; }
.pxc__slot { height: 88px; }
.pxc__cur { scale: 4; }
.pxc__grid { background-size: 8px 8px; }
.pxc__pick { height: 52px; }
.pxc__mini { scale: 1; }
}
@media (prefers-reduced-motion: reduce) {
.pxc.is-demo .pxc__cur { animation: 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);
}
.pxc {
display: flex;
flex-direction: column;
gap: 8px;
width: min(100%, 200px);
}
.px {
display: block;
width: 1px;
height: 1px;
image-rendering: pixelated;
}
.pxc__slot {
position: relative;
display: grid;
place-items: center;
height: 124px;
background: #fff7e6;
border-radius: 8px;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.18);
}
.pxc__slot > .px {
grid-area: 1/1;
}
.pxc__grid {
position: absolute;
inset: 0;
pointer-events: none;
background-image: conic-gradient(rgba(23, 20, 26, 0.08) 25%, transparent 0 50%, rgba(23, 20, 26, 0.08) 0 75%, transparent 0);
background-size: 12px 12px;
}
.pxc__cur {
scale: 6;
opacity: 0;
}
.pxc__cur--arrow {
box-shadow: 0px 0px 0 0 #17141a, 0px 1px 0 0 #17141a, 1px 1px 0 0 #17141a, 0px 2px 0 0 #17141a, 2px 2px 0 0 #17141a, 0px 3px 0 0 #17141a, 3px 3px 0 0 #17141a, 0px 4px 0 0 #17141a, 4px 4px 0 0 #17141a, 0px 5px 0 0 #17141a, 5px 5px 0 0 #17141a, 0px 6px 0 0 #17141a, 6px 6px 0 0 #17141a, 0px 7px 0 0 #17141a, 7px 7px 0 0 #17141a, 0px 8px 0 0 #17141a, 8px 8px 0 0 #17141a, 0px 9px 0 0 #17141a, 9px 9px 0 0 #17141a, 0px 10px 0 0 #17141a, 6px 10px 0 0 #17141a, 7px 10px 0 0 #17141a, 8px 10px 0 0 #17141a, 9px 10px 0 0 #17141a, 10px 10px 0 0 #17141a, 0px 11px 0 0 #17141a, 3px 11px 0 0 #17141a, 6px 11px 0 0 #17141a, 0px 12px 0 0 #17141a, 2px 12px 0 0 #17141a, 4px 12px 0 0 #17141a, 7px 12px 0 0 #17141a, 0px 13px 0 0 #17141a, 1px 13px 0 0 #17141a, 4px 13px 0 0 #17141a, 7px 13px 0 0 #17141a, 0px 14px 0 0 #17141a, 5px 14px 0 0 #17141a, 8px 14px 0 0 #17141a, 5px 15px 0 0 #17141a, 6px 15px 0 0 #17141a, 1px 2px 0 0 #ffffff, 1px 3px 0 0 #ffffff, 2px 3px 0 0 #ffffff, 1px 4px 0 0 #ffffff, 2px 4px 0 0 #ffffff, 3px 4px 0 0 #ffffff, 1px 5px 0 0 #ffffff, 2px 5px 0 0 #ffffff, 3px 5px 0 0 #ffffff, 4px 5px 0 0 #ffffff, 1px 6px 0 0 #ffffff, 2px 6px 0 0 #ffffff, 3px 6px 0 0 #ffffff, 4px 6px 0 0 #ffffff, 5px 6px 0 0 #ffffff, 1px 7px 0 0 #ffffff, 2px 7px 0 0 #ffffff, 3px 7px 0 0 #ffffff, 4px 7px 0 0 #ffffff, 5px 7px 0 0 #ffffff, 6px 7px 0 0 #ffffff, 1px 8px 0 0 #ffffff, 2px 8px 0 0 #ffffff, 3px 8px 0 0 #ffffff, 4px 8px 0 0 #ffffff, 5px 8px 0 0 #ffffff, 6px 8px 0 0 #ffffff, 7px 8px 0 0 #ffffff, 1px 9px 0 0 #ffffff, 2px 9px 0 0 #ffffff, 3px 9px 0 0 #ffffff, 4px 9px 0 0 #ffffff, 5px 9px 0 0 #ffffff, 6px 9px 0 0 #ffffff, 7px 9px 0 0 #ffffff, 8px 9px 0 0 #ffffff, 1px 10px 0 0 #ffffff, 2px 10px 0 0 #ffffff, 3px 10px 0 0 #ffffff, 4px 10px 0 0 #ffffff, 5px 10px 0 0 #ffffff, 1px 11px 0 0 #ffffff, 2px 11px 0 0 #ffffff, 4px 11px 0 0 #ffffff, 5px 11px 0 0 #ffffff, 1px 12px 0 0 #ffffff, 5px 12px 0 0 #ffffff, 6px 12px 0 0 #ffffff, 5px 13px 0 0 #ffffff, 6px 13px 0 0 #ffffff, 6px 14px 0 0 #ffffff, 7px 14px 0 0 #ffffff;
}
.pxc__cur--hour {
box-shadow: 0px 0px 0 0 #17141a, 1px 0px 0 0 #17141a, 2px 0px 0 0 #17141a, 3px 0px 0 0 #17141a, 4px 0px 0 0 #17141a, 5px 0px 0 0 #17141a, 6px 0px 0 0 #17141a, 7px 0px 0 0 #17141a, 8px 0px 0 0 #17141a, 0px 1px 0 0 #17141a, 8px 1px 0 0 #17141a, 0px 2px 0 0 #17141a, 8px 2px 0 0 #17141a, 0px 3px 0 0 #17141a, 8px 3px 0 0 #17141a, 0px 4px 0 0 #17141a, 8px 4px 0 0 #17141a, 0px 5px 0 0 #17141a, 8px 5px 0 0 #17141a, 0px 6px 0 0 #17141a, 8px 6px 0 0 #17141a, 0px 7px 0 0 #17141a, 8px 7px 0 0 #17141a, 0px 8px 0 0 #17141a, 8px 8px 0 0 #17141a, 0px 9px 0 0 #17141a, 8px 9px 0 0 #17141a, 0px 10px 0 0 #17141a, 1px 10px 0 0 #17141a, 2px 10px 0 0 #17141a, 3px 10px 0 0 #17141a, 4px 10px 0 0 #17141a, 5px 10px 0 0 #17141a, 6px 10px 0 0 #17141a, 7px 10px 0 0 #17141a, 8px 10px 0 0 #17141a, 1px 1px 0 0 #ffd23f, 2px 1px 0 0 #ffd23f, 3px 1px 0 0 #ffd23f, 4px 1px 0 0 #ffd23f, 5px 1px 0 0 #ffd23f, 6px 1px 0 0 #ffd23f, 7px 1px 0 0 #ffd23f, 2px 2px 0 0 #ffd23f, 3px 2px 0 0 #ffd23f, 4px 2px 0 0 #ffd23f, 5px 2px 0 0 #ffd23f, 6px 2px 0 0 #ffd23f, 3px 3px 0 0 #ffd23f, 4px 3px 0 0 #ffd23f, 5px 3px 0 0 #ffd23f, 4px 4px 0 0 #ffd23f, 4px 5px 0 0 #ffd23f, 3px 6px 0 0 #ffd23f, 5px 6px 0 0 #ffd23f, 2px 7px 0 0 #ffd23f, 4px 7px 0 0 #ffd23f, 6px 7px 0 0 #ffd23f, 2px 8px 0 0 #ffd23f, 3px 8px 0 0 #ffd23f, 4px 8px 0 0 #ffd23f, 5px 8px 0 0 #ffd23f, 6px 8px 0 0 #ffd23f, 1px 9px 0 0 #ffd23f, 2px 9px 0 0 #ffd23f, 3px 9px 0 0 #ffd23f, 4px 9px 0 0 #ffd23f, 5px 9px 0 0 #ffd23f, 6px 9px 0 0 #ffd23f, 7px 9px 0 0 #ffd23f;
}
.pxc__cur--hand {
box-shadow: 3px 0px 0 0 #17141a, 4px 0px 0 0 #17141a, 2px 1px 0 0 #17141a, 5px 1px 0 0 #17141a, 2px 2px 0 0 #17141a, 5px 2px 0 0 #17141a, 2px 3px 0 0 #17141a, 5px 3px 0 0 #17141a, 2px 4px 0 0 #17141a, 5px 4px 0 0 #17141a, 1px 5px 0 0 #17141a, 2px 5px 0 0 #17141a, 5px 5px 0 0 #17141a, 6px 5px 0 0 #17141a, 0px 6px 0 0 #17141a, 7px 6px 0 0 #17141a, 0px 7px 0 0 #17141a, 7px 7px 0 0 #17141a, 0px 8px 0 0 #17141a, 7px 8px 0 0 #17141a, 0px 9px 0 0 #17141a, 7px 9px 0 0 #17141a, 0px 10px 0 0 #17141a, 7px 10px 0 0 #17141a, 1px 11px 0 0 #17141a, 6px 11px 0 0 #17141a, 1px 12px 0 0 #17141a, 2px 12px 0 0 #17141a, 3px 12px 0 0 #17141a, 4px 12px 0 0 #17141a, 5px 12px 0 0 #17141a, 6px 12px 0 0 #17141a, 3px 1px 0 0 #ffffff, 4px 1px 0 0 #ffffff, 3px 2px 0 0 #ffffff, 4px 2px 0 0 #ffffff, 3px 3px 0 0 #ffffff, 4px 3px 0 0 #ffffff, 3px 4px 0 0 #ffffff, 4px 4px 0 0 #ffffff, 3px 5px 0 0 #ffffff, 4px 5px 0 0 #ffffff, 1px 6px 0 0 #ffffff, 2px 6px 0 0 #ffffff, 3px 6px 0 0 #ffffff, 4px 6px 0 0 #ffffff, 5px 6px 0 0 #ffffff, 6px 6px 0 0 #ffffff, 1px 7px 0 0 #ffffff, 2px 7px 0 0 #ffffff, 3px 7px 0 0 #ffffff, 4px 7px 0 0 #ffffff, 5px 7px 0 0 #ffffff, 6px 7px 0 0 #ffffff, 1px 8px 0 0 #ffffff, 2px 8px 0 0 #ffffff, 3px 8px 0 0 #ffffff, 4px 8px 0 0 #ffffff, 5px 8px 0 0 #ffffff, 6px 8px 0 0 #ffffff, 1px 9px 0 0 #ffffff, 2px 9px 0 0 #ffffff, 3px 9px 0 0 #ffffff, 4px 9px 0 0 #ffffff, 5px 9px 0 0 #ffffff, 6px 9px 0 0 #ffffff, 1px 10px 0 0 #ffffff, 2px 10px 0 0 #ffffff, 3px 10px 0 0 #ffffff, 4px 10px 0 0 #ffffff, 5px 10px 0 0 #ffffff, 6px 10px 0 0 #ffffff, 2px 11px 0 0 #ffffff, 3px 11px 0 0 #ffffff, 4px 11px 0 0 #ffffff, 5px 11px 0 0 #ffffff;
}
.pxc__slot[data-cur=arrow] .pxc__cur--arrow {
opacity: 1;
}
.pxc__slot[data-cur=hour] .pxc__cur--hour {
opacity: 1;
}
.pxc__slot[data-cur=hand] .pxc__cur--hand {
opacity: 1;
}
.pxc__row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.pxc__pick {
display: grid;
justify-items: center;
align-content: center;
gap: 4px;
box-sizing: border-box;
height: 64px;
padding: 4px;
border: 0;
background: #fff7e6;
color: #17141a;
border-radius: 4px;
font-family: inherit;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.pxc__pick.is-on {
box-shadow: inset 0 0 0 2px #17141a;
}
.pxc__mini {
scale: 2;
}
.pxc__mini--arrow {
box-shadow: 0px 0px 0 0 #17141a, 0px 1px 0 0 #17141a, 1px 1px 0 0 #17141a, 0px 2px 0 0 #17141a, 2px 2px 0 0 #17141a, 0px 3px 0 0 #17141a, 3px 3px 0 0 #17141a, 0px 4px 0 0 #17141a, 4px 4px 0 0 #17141a, 0px 5px 0 0 #17141a, 5px 5px 0 0 #17141a, 0px 6px 0 0 #17141a, 6px 6px 0 0 #17141a, 0px 7px 0 0 #17141a, 7px 7px 0 0 #17141a, 0px 8px 0 0 #17141a, 8px 8px 0 0 #17141a, 0px 9px 0 0 #17141a, 9px 9px 0 0 #17141a, 0px 10px 0 0 #17141a, 6px 10px 0 0 #17141a, 7px 10px 0 0 #17141a, 8px 10px 0 0 #17141a, 9px 10px 0 0 #17141a, 10px 10px 0 0 #17141a, 0px 11px 0 0 #17141a, 3px 11px 0 0 #17141a, 6px 11px 0 0 #17141a, 0px 12px 0 0 #17141a, 2px 12px 0 0 #17141a, 4px 12px 0 0 #17141a, 7px 12px 0 0 #17141a, 0px 13px 0 0 #17141a, 1px 13px 0 0 #17141a, 4px 13px 0 0 #17141a, 7px 13px 0 0 #17141a, 0px 14px 0 0 #17141a, 5px 14px 0 0 #17141a, 8px 14px 0 0 #17141a, 5px 15px 0 0 #17141a, 6px 15px 0 0 #17141a, 1px 2px 0 0 #ffffff, 1px 3px 0 0 #ffffff, 2px 3px 0 0 #ffffff, 1px 4px 0 0 #ffffff, 2px 4px 0 0 #ffffff, 3px 4px 0 0 #ffffff, 1px 5px 0 0 #ffffff, 2px 5px 0 0 #ffffff, 3px 5px 0 0 #ffffff, 4px 5px 0 0 #ffffff, 1px 6px 0 0 #ffffff, 2px 6px 0 0 #ffffff, 3px 6px 0 0 #ffffff, 4px 6px 0 0 #ffffff, 5px 6px 0 0 #ffffff, 1px 7px 0 0 #ffffff, 2px 7px 0 0 #ffffff, 3px 7px 0 0 #ffffff, 4px 7px 0 0 #ffffff, 5px 7px 0 0 #ffffff, 6px 7px 0 0 #ffffff, 1px 8px 0 0 #ffffff, 2px 8px 0 0 #ffffff, 3px 8px 0 0 #ffffff, 4px 8px 0 0 #ffffff, 5px 8px 0 0 #ffffff, 6px 8px 0 0 #ffffff, 7px 8px 0 0 #ffffff, 1px 9px 0 0 #ffffff, 2px 9px 0 0 #ffffff, 3px 9px 0 0 #ffffff, 4px 9px 0 0 #ffffff, 5px 9px 0 0 #ffffff, 6px 9px 0 0 #ffffff, 7px 9px 0 0 #ffffff, 8px 9px 0 0 #ffffff, 1px 10px 0 0 #ffffff, 2px 10px 0 0 #ffffff, 3px 10px 0 0 #ffffff, 4px 10px 0 0 #ffffff, 5px 10px 0 0 #ffffff, 1px 11px 0 0 #ffffff, 2px 11px 0 0 #ffffff, 4px 11px 0 0 #ffffff, 5px 11px 0 0 #ffffff, 1px 12px 0 0 #ffffff, 5px 12px 0 0 #ffffff, 6px 12px 0 0 #ffffff, 5px 13px 0 0 #ffffff, 6px 13px 0 0 #ffffff, 6px 14px 0 0 #ffffff, 7px 14px 0 0 #ffffff;
}
.pxc__mini--hour {
box-shadow: 0px 0px 0 0 #17141a, 1px 0px 0 0 #17141a, 2px 0px 0 0 #17141a, 3px 0px 0 0 #17141a, 4px 0px 0 0 #17141a, 5px 0px 0 0 #17141a, 6px 0px 0 0 #17141a, 7px 0px 0 0 #17141a, 8px 0px 0 0 #17141a, 0px 1px 0 0 #17141a, 8px 1px 0 0 #17141a, 0px 2px 0 0 #17141a, 8px 2px 0 0 #17141a, 0px 3px 0 0 #17141a, 8px 3px 0 0 #17141a, 0px 4px 0 0 #17141a, 8px 4px 0 0 #17141a, 0px 5px 0 0 #17141a, 8px 5px 0 0 #17141a, 0px 6px 0 0 #17141a, 8px 6px 0 0 #17141a, 0px 7px 0 0 #17141a, 8px 7px 0 0 #17141a, 0px 8px 0 0 #17141a, 8px 8px 0 0 #17141a, 0px 9px 0 0 #17141a, 8px 9px 0 0 #17141a, 0px 10px 0 0 #17141a, 1px 10px 0 0 #17141a, 2px 10px 0 0 #17141a, 3px 10px 0 0 #17141a, 4px 10px 0 0 #17141a, 5px 10px 0 0 #17141a, 6px 10px 0 0 #17141a, 7px 10px 0 0 #17141a, 8px 10px 0 0 #17141a, 1px 1px 0 0 #ffd23f, 2px 1px 0 0 #ffd23f, 3px 1px 0 0 #ffd23f, 4px 1px 0 0 #ffd23f, 5px 1px 0 0 #ffd23f, 6px 1px 0 0 #ffd23f, 7px 1px 0 0 #ffd23f, 2px 2px 0 0 #ffd23f, 3px 2px 0 0 #ffd23f, 4px 2px 0 0 #ffd23f, 5px 2px 0 0 #ffd23f, 6px 2px 0 0 #ffd23f, 3px 3px 0 0 #ffd23f, 4px 3px 0 0 #ffd23f, 5px 3px 0 0 #ffd23f, 4px 4px 0 0 #ffd23f, 4px 5px 0 0 #ffd23f, 3px 6px 0 0 #ffd23f, 5px 6px 0 0 #ffd23f, 2px 7px 0 0 #ffd23f, 4px 7px 0 0 #ffd23f, 6px 7px 0 0 #ffd23f, 2px 8px 0 0 #ffd23f, 3px 8px 0 0 #ffd23f, 4px 8px 0 0 #ffd23f, 5px 8px 0 0 #ffd23f, 6px 8px 0 0 #ffd23f, 1px 9px 0 0 #ffd23f, 2px 9px 0 0 #ffd23f, 3px 9px 0 0 #ffd23f, 4px 9px 0 0 #ffd23f, 5px 9px 0 0 #ffd23f, 6px 9px 0 0 #ffd23f, 7px 9px 0 0 #ffd23f;
}
.pxc__mini--hand {
box-shadow: 3px 0px 0 0 #17141a, 4px 0px 0 0 #17141a, 2px 1px 0 0 #17141a, 5px 1px 0 0 #17141a, 2px 2px 0 0 #17141a, 5px 2px 0 0 #17141a, 2px 3px 0 0 #17141a, 5px 3px 0 0 #17141a, 2px 4px 0 0 #17141a, 5px 4px 0 0 #17141a, 1px 5px 0 0 #17141a, 2px 5px 0 0 #17141a, 5px 5px 0 0 #17141a, 6px 5px 0 0 #17141a, 0px 6px 0 0 #17141a, 7px 6px 0 0 #17141a, 0px 7px 0 0 #17141a, 7px 7px 0 0 #17141a, 0px 8px 0 0 #17141a, 7px 8px 0 0 #17141a, 0px 9px 0 0 #17141a, 7px 9px 0 0 #17141a, 0px 10px 0 0 #17141a, 7px 10px 0 0 #17141a, 1px 11px 0 0 #17141a, 6px 11px 0 0 #17141a, 1px 12px 0 0 #17141a, 2px 12px 0 0 #17141a, 3px 12px 0 0 #17141a, 4px 12px 0 0 #17141a, 5px 12px 0 0 #17141a, 6px 12px 0 0 #17141a, 3px 1px 0 0 #ffffff, 4px 1px 0 0 #ffffff, 3px 2px 0 0 #ffffff, 4px 2px 0 0 #ffffff, 3px 3px 0 0 #ffffff, 4px 3px 0 0 #ffffff, 3px 4px 0 0 #ffffff, 4px 4px 0 0 #ffffff, 3px 5px 0 0 #ffffff, 4px 5px 0 0 #ffffff, 1px 6px 0 0 #ffffff, 2px 6px 0 0 #ffffff, 3px 6px 0 0 #ffffff, 4px 6px 0 0 #ffffff, 5px 6px 0 0 #ffffff, 6px 6px 0 0 #ffffff, 1px 7px 0 0 #ffffff, 2px 7px 0 0 #ffffff, 3px 7px 0 0 #ffffff, 4px 7px 0 0 #ffffff, 5px 7px 0 0 #ffffff, 6px 7px 0 0 #ffffff, 1px 8px 0 0 #ffffff, 2px 8px 0 0 #ffffff, 3px 8px 0 0 #ffffff, 4px 8px 0 0 #ffffff, 5px 8px 0 0 #ffffff, 6px 8px 0 0 #ffffff, 1px 9px 0 0 #ffffff, 2px 9px 0 0 #ffffff, 3px 9px 0 0 #ffffff, 4px 9px 0 0 #ffffff, 5px 9px 0 0 #ffffff, 6px 9px 0 0 #ffffff, 1px 10px 0 0 #ffffff, 2px 10px 0 0 #ffffff, 3px 10px 0 0 #ffffff, 4px 10px 0 0 #ffffff, 5px 10px 0 0 #ffffff, 6px 10px 0 0 #ffffff, 2px 11px 0 0 #ffffff, 3px 11px 0 0 #ffffff, 4px 11px 0 0 #ffffff, 5px 11px 0 0 #ffffff;
}
.pxc.is-demo .pxc__cur {
animation-duration: 2s;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pxc.is-demo .pxc__cur--arrow {
animation-name: pxcArrowO, pxcArrowM;
animation-timing-function: steps(1, end), ease-out;
}
.pxc.is-demo .pxc__cur--hour {
animation-name: pxcHourO, pxcHourR;
animation-timing-function: steps(1, end), linear;
}
.pxc.is-demo .pxc__cur--hand {
animation-name: pxcHandO, pxcHandM;
animation-timing-function: steps(1, end), ease-out;
}
@keyframes pxcArrowO {
0%, 29% {
opacity: 1;
}
30%, 88% {
opacity: 0;
}
89%, 100% {
opacity: 1;
}
}
@keyframes pxcArrowM {
0% {
translate: 0 0;
}
10% {
translate: 3px 2px;
}
20% {
translate: -2px 4px;
}
29%, 100% {
translate: 0 0;
}
}
@keyframes pxcHourO {
0%, 29% {
opacity: 0;
}
30%, 62% {
opacity: 1;
}
63%, 100% {
opacity: 0;
}
}
@keyframes pxcHourR {
0%, 30% {
rotate: 0deg;
}
62%, 100% {
rotate: 180deg;
}
}
@keyframes pxcHandO {
0%, 62% {
opacity: 0;
}
63%, 88% {
opacity: 1;
}
89%, 100% {
opacity: 0;
}
}
@keyframes pxcHandM {
0%, 63% {
translate: 0 0;
}
72% {
translate: 2px -2px;
}
80% {
translate: -2px 0;
}
88%, 100% {
translate: 0 0;
}
}
@media (max-width: 340px) {
.pxc {
gap: 4px;
}
.pxc__slot {
height: 88px;
}
.pxc__cur {
scale: 4;
}
.pxc__grid {
background-size: 8px 8px;
}
.pxc__pick {
height: 52px;
}
.pxc__mini {
scale: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.pxc.is-demo .pxc__cur {
animation: none !important;
}
}var pxc = document.getElementById('pxc');
var slot = document.getElementById('slot');
var picks = [].slice.call(document.querySelectorAll('.pxc__pick'));
picks.forEach(function (btn) {
btn.addEventListener('click', function () {
pxc.classList.remove('is-demo');
slot.dataset.cur = btn.dataset.set;
picks.forEach(function (other) {
var on = other === btn;
other.classList.toggle('is-on', on);
other.setAttribute('aria-pressed', on ? 'true' : 'false');
});
});
});// PixelCursors — vanilla 판(run/023/demo/03-os-pixel-cursor-set/index.html) 의 React 포트.
// 화살표·모래시계·손가락을 1px 격자 그림으로 그려 scale 로 정수배 확대한다. 고르기 단추가
// data-cur 값을 바꿔 큰 칸의 커서를 갈아 낀다. 미리보기 루프는 CSS keyframes 그대로다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PixelCursors.module.scss";
export type PixelCursorsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const PICKS: { set: string; label: string }[] = [
{ set: "arrow", label: "화살표" },
{ set: "hour", label: "모래시계" },
{ set: "hand", label: "손가락" }
];
const ARIA_SLOT = "커서 미리보기";
const ARIA_GROUP = "커서 고르기";
export default function PixelCursors(props: PixelCursorsProps) {
const vars: Vars = {
"--duration": (props.duration ?? 2000) + "ms",
"--easing": props.easing ?? "ease-out",
"--color": props.color ?? "#ffd23f"
};
const [preview, setPreview] = useState<boolean>(true);
const [cur, setCur] = useState<string>("arrow");
return (
<main className={styles.stage}>
<div
className={styles.pxc + (preview ? " " + styles.isDemo : "")}
style={vars}
onPointerDown={() => setPreview(false)}
>
<div className={styles.slot} data-cur={cur} aria-label={ARIA_SLOT}>
<span className={styles.px + " " + styles.curArrow} aria-hidden="true" />
<span className={styles.px + " " + styles.curHour} aria-hidden="true" />
<span className={styles.px + " " + styles.curHand} aria-hidden="true" />
<span className={styles.grid} aria-hidden="true" />
</div>
<div className={styles.row} role="group" aria-label={ARIA_GROUP}>
{PICKS.map((p) => (
<button
key={p.set}
type="button"
className={styles.pick + (cur === p.set ? " " + styles.pickOn : "")}
aria-pressed={cur === p.set}
onClick={() => {
setPreview(false);
setCur(p.set);
}}
>
<span
className={
styles.px + " " + styles.mini + " " + styles["mini" + p.set.charAt(0).toUpperCase() + p.set.slice(1)]
}
aria-hidden="true"
/>
<span className={styles.lbl}>{p.label}</span>
</button>
))}
</div>
</div>
</main>
);
}03 · 픽셀 커서 세 종류 — 측정 누적면적 9.179% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// ── 픽셀 그림 — 가로 줄 runs (x y 폭). K 는 검은 외곽선, W 는 흰 채움, S 는 모래
@function pxRuns($runs, $c) {
$s: ();
@each $r in $runs {
@for $i from 0 through list.nth($r, 3) - 1 {
$s: list.append($s, ((list.nth($r, 1) + $i) * 1px) (list.nth($r, 2) * 1px) 0 0 $c, comma);
}
}
@return $s;
}
가장 촘촘하게 움직이는 교체
단추마다 실제 크기 두 배 미리보기를 갖추고, 움직인 프레임 22/24 로 아홉 개 중 가장 촘촘하니, 커서까지 갈아 끼우는 테마 페이지에 그대로 씁니다.
04부팅 점검 줄 출력
한 줄씩 쌓이고 한 박자 늦게 결과가 붙는 점검
ms dos 화면을 연상시키는 장치 점검 줄 다섯 개가 clip-path 오른쪽 밀기로 한 줄씩 쌓이고, 각 줄 오른쪽 끝에는 결과 글자가 한 박자 늦게 컷으로 붙은 뒤 8px 옆에서 제자리로 미끄러집니다. 마지막 줄까지 차면 화면이 한 번 비워지고 로고가 다시 나타나며 처음부터 셉니다.

<main class="stage">
<div class="bl is-demo" id="bl">
<div class="bl__bar">
<span class="bl__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="M200 48H56a8 8 0 0 0-8 8v144a8 8 0 0 0 8 8h144a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8m-48 104h-48v-48h48Z" opacity=".2"/><path d="M152 96h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8m-8 48h-32v-32h32Zm88 0h-16v-32h16a8 8 0 0 0 0-16h-16V56a16 16 0 0 0-16-16h-40V24a8 8 0 0 0-16 0v16h-32V24a8 8 0 0 0-16 0v16H56a16 16 0 0 0-16 16v40H24a8 8 0 0 0 0 16h16v32H24a8 8 0 0 0 0 16h16v40a16 16 0 0 0 16 16h40v16a8 8 0 0 0 16 0v-16h32v16a8 8 0 0 0 16 0v-16h40a16 16 0 0 0 16-16v-40h16a8 8 0 0 0 0-16m-32 56H56V56h144z"/></g></svg></span>
<span class="bl__name">GODRICH BIOS</span>
<span class="bl__rev">REV 2.4</span>
</div>
<div class="bl__scr" id="scr" tabindex="0" role="button" aria-label="부팅 점검 다시 시작">
<div class="bl__logo" aria-hidden="true">
<span class="bl__lg1">GODRICH</span>
<span class="bl__lg2">부팅 점검</span>
</div>
<ul class="bl__lines">
<li class="bl__row bl__row--1"><span class="bl__lab">메모리 검사</span><span class="bl__res">65,536 KB</span></li>
<li class="bl__row bl__row--2"><span class="bl__lab">주 디스크</span><span class="bl__res">준비</span></li>
<li class="bl__row bl__row--3"><span class="bl__lab">키보드 연결</span><span class="bl__res">확인</span></li>
<li class="bl__row bl__row--4"><span class="bl__lab">디스플레이</span><span class="bl__res">640×480</span></li>
<li class="bl__row bl__row--5"><span class="bl__lab">부팅 장치</span><span class="bl__res">OK</span></li>
</ul>
</div>
<p class="bl__note">점검 줄이 한 줄씩 쌓이고 한 박자 늦게 결과가 붙습니다. 화면을 누르면 처음부터 다시 셉니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 부팅 점검 줄 출력 — 장치 점검 줄이 한 줄씩 아래로 쌓이고, 각 줄 오른쪽 끝에 결과 글자가
// 한 박자 늦게 붙는다. 마지막 줄까지 차면 화면이 한 번 비워지고(로고가 컷으로 돌아온 뒤 사라진다)
// 처음부터 다시 센다. 줄 등장은 clip-path 오른쪽 밀기, 결과는 컷 + 8px transform 정착.
// 타이핑 커서 점멸이나 아스키 진행바는 쓰지 않는다(294 터미널 편과 겹치지 않게).
$duration: $dur-loop;
$easing: $ease-out;
$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: 13px;
$lh: 22px;
@include stage($stage-paper);
.bl {
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;
}
// ── 머리 줄: BIOS 이름과 판번호
.bl__bar { display: flex; align-items: center; gap: $sp-2; }
.bl__ico { display: grid; place-items: center; color: $subject-blue; font-size: 16px; }
.bl__name {
flex: 1 1 auto; color: $subject-ink;
font-family: $mono; font-weight: 700; font-size: 12px; line-height: 1.4;
letter-spacing: 0.04em;
}
.bl__rev {
padding: $sp-1 $sp-2; border-radius: $r-xs;
background: $color; color: $stage-paper;
font-family: $mono; font-weight: 700; font-size: 10px; line-height: 1.4;
}
// ── 화면: 로고 블록과 점검 줄이 사는 곳. 누르면 처음부터 다시 센다
.bl__scr {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; padding: $sp-2;
background: $stage-paper; border: 1px solid $edge; border-radius: $r-sm;
}
.bl__scr:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 로고 블록 — 루프에서만 잠깐 보이는 상태이므로 기본은 가려둔다(정적 경로는 줄이 채운다)
.bl__logo {
display: flex; align-items: baseline; gap: $sp-2;
padding: $sp-1 $sp-2; border-radius: $r-xs;
background: $color; color: $stage-paper;
clip-path: inset(0 100% 0 0);
}
.bl__lg1 {
font-family: $mono; font-weight: 700; font-size: 16px; line-height: 1.4;
letter-spacing: 0.08em;
}
.bl__lg2 { color: rgba(255, 255, 255, .8); font-weight: 500; font-size: 10px; line-height: 1.4; }
// ── 점검 줄: 왼쪽 장치 이름, 오른쪽 끝 결과 글자
.bl__lines { display: flex; flex-direction: column; margin: 0; padding: 0; list-style: none; }
.bl__row {
display: flex; align-items: center; justify-content: space-between;
height: $lh;
font-family: $mono; font-weight: 500; font-size: $fs; line-height: $lh;
}
.bl__lab { color: $subject-ink; white-space: nowrap; }
.bl__res { color: $color; font-weight: 700; white-space: nowrap; }
.bl__note { margin: 0; color: $muted; font-weight: 500; font-size: 11px; line-height: 1.4; }
// ── 격자 미리보기 루프(2초)
// 0% 로고가 오른쪽에서 밀려 들어오고 6% 에 완전히 나타난다. 8% 부터 왼쪽으로 빠지며
// 줄이 한 줄씩 쌓인다(12·24·36·48·60%), 결과는 각 줄이 끝난 한 박자 뒤에 컷으로 붙고
// 8px 옆에서 제자리로 미끄러져 정착한다. 75% 에 마지막 줄까지 찼다면 76% 에 줄 전체가
// 비워지며 로고가 다시 컷 인 — 이 프레임이 poster 다. 88% 부터 로고는 오른쪽으로 빠져
// 90% 이후 잠깐 빈 화면(한 번 비워지는 박자)이 되고 100% 에 이르러 처음으로 돌아온다.
.bl.is-demo .bl__logo {
animation-name: blLogo;
animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.bl.is-demo .bl__row {
animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.bl.is-demo .bl__res {
animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
// 줄마다 등장 시각은 nth-child 지연 대신 개별 키프레임으로 나눈다 — 애니메이션 단축은
// delay 를 0 으로 되돌리므로 낱개 속성만 쓴다(240 실측)
.bl.is-demo .bl__row--1 { animation-name: blRow1; }
.bl.is-demo .bl__row--1 .bl__res { animation-name: blRes1; }
.bl.is-demo .bl__row--2 { animation-name: blRow2; }
.bl.is-demo .bl__row--2 .bl__res { animation-name: blRes2; }
.bl.is-demo .bl__row--3 { animation-name: blRow3; }
.bl.is-demo .bl__row--3 .bl__res { animation-name: blRes3; }
.bl.is-demo .bl__row--4 { animation-name: blRow4; }
.bl.is-demo .bl__row--4 .bl__res { animation-name: blRes4; }
.bl.is-demo .bl__row--5 { animation-name: blRow5; }
.bl.is-demo .bl__row--5 .bl__res { animation-name: blRes5; }
@keyframes blLogo {
0% { clip-path: inset(0 100% 0 0); }
6%, 8% { clip-path: inset(0 0 0 0); }
14%, 75% { clip-path: inset(0 0 0 100%); }
76%, 88% { clip-path: inset(0 0 0 0); }
90%, 100% { clip-path: inset(0 100% 0 0); }
}
@keyframes blRow1 {
0%, 12% { clip-path: inset(0 100% 0 0); }
17%, 75% { clip-path: inset(0 0 0 0); }
76%, 100% { clip-path: inset(0 100% 0 0); }
}
@keyframes blRow2 {
0%, 24% { clip-path: inset(0 100% 0 0); }
29%, 75% { clip-path: inset(0 0 0 0); }
76%, 100% { clip-path: inset(0 100% 0 0); }
}
@keyframes blRow3 {
0%, 36% { clip-path: inset(0 100% 0 0); }
41%, 75% { clip-path: inset(0 0 0 0); }
76%, 100% { clip-path: inset(0 100% 0 0); }
}
@keyframes blRow4 {
0%, 48% { clip-path: inset(0 100% 0 0); }
53%, 75% { clip-path: inset(0 0 0 0); }
76%, 100% { clip-path: inset(0 100% 0 0); }
}
@keyframes blRow5 {
0%, 60% { clip-path: inset(0 100% 0 0); }
65%, 75% { clip-path: inset(0 0 0 0); }
76%, 100% { clip-path: inset(0 100% 0 0); }
}
// 결과 — 컷으로 붙고(1% 안에 opacity 0→1) 이어서 8px 옆에서 제자리로 정착한다
@keyframes blRes1 {
0%, 17% { opacity: 0; transform: translateX(8px); }
18% { opacity: 1; transform: translateX(8px); }
21%, 75% { opacity: 1; transform: translateX(0); }
76%, 100% { opacity: 0; transform: translateX(0); }
}
@keyframes blRes2 {
0%, 29% { opacity: 0; transform: translateX(8px); }
30% { opacity: 1; transform: translateX(8px); }
33%, 75% { opacity: 1; transform: translateX(0); }
76%, 100% { opacity: 0; transform: translateX(0); }
}
@keyframes blRes3 {
0%, 41% { opacity: 0; transform: translateX(8px); }
42% { opacity: 1; transform: translateX(8px); }
45%, 75% { opacity: 1; transform: translateX(0); }
76%, 100% { opacity: 0; transform: translateX(0); }
}
@keyframes blRes4 {
0%, 53% { opacity: 0; transform: translateX(8px); }
54% { opacity: 1; transform: translateX(8px); }
57%, 75% { opacity: 1; transform: translateX(0); }
76%, 100% { opacity: 0; transform: translateX(0); }
}
@keyframes blRes5 {
0%, 65% { opacity: 0; transform: translateX(8px); }
66% { opacity: 1; transform: translateX(8px); }
69%, 75% { opacity: 1; transform: translateX(0); }
76%, 100% { opacity: 0; transform: translateX(0); }
}
// ── 좁은 화면: 글자와 줄 높이를 줄여 무대 안에 들어온다
@media (max-width: 340px) {
.bl { gap: $sp-1; padding: $sp-2; }
.bl__ico { font-size: 13px; }
.bl__name { font-size: 10px; }
.bl__rev { padding: 0 $sp-1; font-size: 9px; }
.bl__scr { gap: $sp-1; padding: $sp-1; }
.bl__logo { padding: 0 $sp-1; }
.bl__lg1 { font-size: 12px; }
.bl__lg2 { font-size: 9px; }
.bl__row { height: 13px; font-size: 10px; line-height: 13px; }
.bl__note { font-size: 9px; line-height: 1.3; }
}
@media (prefers-reduced-motion: reduce) {
.bl.is-demo .bl__logo, .bl.is-demo .bl__row, .bl.is-demo .bl__res {
animation: none !important;
}
// 줄은 그대로 보이고(정적 경로와 같은 모습) 로고만 가려진 채 머무른다
.bl.is-demo .bl__row { clip-path: none !important; }
.bl.is-demo .bl__res { opacity: 1 !important; transform: none !important; }
.bl.is-demo .bl__logo { clip-path: inset(0 100% 0 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);
}
.bl {
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);
}
.bl__bar {
display: flex;
align-items: center;
gap: 8px;
}
.bl__ico {
display: grid;
place-items: center;
color: #2f6df6;
font-size: 16px;
}
.bl__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;
letter-spacing: 0.04em;
}
.bl__rev {
padding: 4px 8px;
border-radius: 4px;
background: #0a4fe8;
color: #ffffff;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.bl__scr {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
padding: 8px;
background: #ffffff;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 8px;
}
.bl__scr:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.bl__logo {
display: flex;
align-items: baseline;
gap: 8px;
padding: 4px 8px;
border-radius: 4px;
background: #0a4fe8;
color: #ffffff;
clip-path: inset(0 100% 0 0);
}
.bl__lg1 {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 16px;
line-height: 1.4;
letter-spacing: 0.08em;
}
.bl__lg2 {
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
font-size: 10px;
line-height: 1.4;
}
.bl__lines {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
list-style: none;
}
.bl__row {
display: flex;
align-items: center;
justify-content: space-between;
height: 22px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 13px;
line-height: 22px;
}
.bl__lab {
color: #17141a;
white-space: nowrap;
}
.bl__res {
color: #0a4fe8;
font-weight: 700;
white-space: nowrap;
}
.bl__note {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.bl.is-demo .bl__logo {
animation-name: blLogo;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.bl.is-demo .bl__row {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.bl.is-demo .bl__res {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.bl.is-demo .bl__row--1 {
animation-name: blRow1;
}
.bl.is-demo .bl__row--1 .bl__res {
animation-name: blRes1;
}
.bl.is-demo .bl__row--2 {
animation-name: blRow2;
}
.bl.is-demo .bl__row--2 .bl__res {
animation-name: blRes2;
}
.bl.is-demo .bl__row--3 {
animation-name: blRow3;
}
.bl.is-demo .bl__row--3 .bl__res {
animation-name: blRes3;
}
.bl.is-demo .bl__row--4 {
animation-name: blRow4;
}
.bl.is-demo .bl__row--4 .bl__res {
animation-name: blRes4;
}
.bl.is-demo .bl__row--5 {
animation-name: blRow5;
}
.bl.is-demo .bl__row--5 .bl__res {
animation-name: blRes5;
}
@keyframes blLogo {
0% {
clip-path: inset(0 100% 0 0);
}
6%, 8% {
clip-path: inset(0 0 0 0);
}
14%, 75% {
clip-path: inset(0 0 0 100%);
}
76%, 88% {
clip-path: inset(0 0 0 0);
}
90%, 100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes blRow1 {
0%, 12% {
clip-path: inset(0 100% 0 0);
}
17%, 75% {
clip-path: inset(0 0 0 0);
}
76%, 100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes blRow2 {
0%, 24% {
clip-path: inset(0 100% 0 0);
}
29%, 75% {
clip-path: inset(0 0 0 0);
}
76%, 100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes blRow3 {
0%, 36% {
clip-path: inset(0 100% 0 0);
}
41%, 75% {
clip-path: inset(0 0 0 0);
}
76%, 100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes blRow4 {
0%, 48% {
clip-path: inset(0 100% 0 0);
}
53%, 75% {
clip-path: inset(0 0 0 0);
}
76%, 100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes blRow5 {
0%, 60% {
clip-path: inset(0 100% 0 0);
}
65%, 75% {
clip-path: inset(0 0 0 0);
}
76%, 100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes blRes1 {
0%, 17% {
opacity: 0;
transform: translateX(8px);
}
18% {
opacity: 1;
transform: translateX(8px);
}
21%, 75% {
opacity: 1;
transform: translateX(0);
}
76%, 100% {
opacity: 0;
transform: translateX(0);
}
}
@keyframes blRes2 {
0%, 29% {
opacity: 0;
transform: translateX(8px);
}
30% {
opacity: 1;
transform: translateX(8px);
}
33%, 75% {
opacity: 1;
transform: translateX(0);
}
76%, 100% {
opacity: 0;
transform: translateX(0);
}
}
@keyframes blRes3 {
0%, 41% {
opacity: 0;
transform: translateX(8px);
}
42% {
opacity: 1;
transform: translateX(8px);
}
45%, 75% {
opacity: 1;
transform: translateX(0);
}
76%, 100% {
opacity: 0;
transform: translateX(0);
}
}
@keyframes blRes4 {
0%, 53% {
opacity: 0;
transform: translateX(8px);
}
54% {
opacity: 1;
transform: translateX(8px);
}
57%, 75% {
opacity: 1;
transform: translateX(0);
}
76%, 100% {
opacity: 0;
transform: translateX(0);
}
}
@keyframes blRes5 {
0%, 65% {
opacity: 0;
transform: translateX(8px);
}
66% {
opacity: 1;
transform: translateX(8px);
}
69%, 75% {
opacity: 1;
transform: translateX(0);
}
76%, 100% {
opacity: 0;
transform: translateX(0);
}
}
@media (max-width: 340px) {
.bl {
gap: 4px;
padding: 8px;
}
.bl__ico {
font-size: 13px;
}
.bl__name {
font-size: 10px;
}
.bl__rev {
padding: 0 4px;
font-size: 9px;
}
.bl__scr {
gap: 4px;
padding: 4px;
}
.bl__logo {
padding: 0 4px;
}
.bl__lg1 {
font-size: 12px;
}
.bl__lg2 {
font-size: 9px;
}
.bl__row {
height: 13px;
font-size: 10px;
line-height: 13px;
}
.bl__note {
font-size: 9px;
line-height: 1.3;
}
}
@media (prefers-reduced-motion: reduce) {
.bl.is-demo .bl__logo, .bl.is-demo .bl__row, .bl.is-demo .bl__res {
animation: none !important;
}
.bl.is-demo .bl__row {
clip-path: none !important;
}
.bl.is-demo .bl__res {
opacity: 1 !important;
transform: none !important;
}
.bl.is-demo .bl__logo {
clip-path: inset(0 100% 0 0) !important;
}
}var bl = document.getElementById('bl');
var scr = document.getElementById('scr');
// 화면을 누르면(또는 Enter·Space) 루프를 처음부터 다시 돌린다 — 클래스를 떼었다 붙이며 재시작
function restart() {
bl.classList.remove('is-demo');
void bl.offsetWidth;
bl.classList.add('is-demo');
}
scr.addEventListener('click', restart);
scr.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); restart(); }
});// BootLines — vanilla 판(run/023/demo/04-os-boot-sequence-lines/index.html) 의 React 포트.
// 점검 줄이 한 줄씩 쌓이고 결과가 한 박자 늦게 붙는다. 마지막 줄 뒤 화면이 한 번 비워지고
// 다시 센다. 화면을 누르면 key 를 바꿔 통째로 다시 마운트해 루프를 처음부터 돌린다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BootLines.module.scss";
export type BootLinesProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 타입에 React 이벤트 제네릭이 없다 — 쓰는 것만 로컬 타입으로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
const SCR_ARIA = "부팅 점검 다시 시작";
const LOGO_SUB = "부팅 점검";
const NOTE = "점검 줄이 한 줄씩 쌓이고 한 박자 늦게 결과가 붙습니다. 화면을 누르면 처음부터 다시 셉니다";
const ROWS = [
{ lab: "메모리 검사", res: "65,536 KB" },
{ lab: "주 디스크", res: "준비" },
{ lab: "키보드 연결", res: "확인" },
{ lab: "디스플레이", res: "640×480" },
{ lab: "부팅 장치", res: "OK" }
];
const CPU_ICON = (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M200 48H56a8 8 0 0 0-8 8v144a8 8 0 0 0 8 8h144a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8m-48 104h-48v-48h48Z" opacity=".2" />
<path d="M152 96h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8m-8 48h-32v-32h32Zm88 0h-16v-32h16a8 8 0 0 0 0-16h-16V56a16 16 0 0 0-16-16h-40V24a8 8 0 0 0-16 0v16h-32V24a8 8 0 0 0-16 0v16H56a16 16 0 0 0-16 16v40H24a8 8 0 0 0 0 16h16v32H24a8 8 0 0 0 0 16h16v40a16 16 0 0 0 16 16h40v16a8 8 0 0 0 16 0v-16h32v16a8 8 0 0 0 16 0v-16h40a16 16 0 0 0 16-16v-40h16a8 8 0 0 0 0-16m-32 56H56V56h144z" />
</g>
</svg>
);
export default function BootLines(props: BootLinesProps) {
const duration = props.duration ?? 2000;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#0a4fe8";
const [run, setRun] = useState(0);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const restart = () => setRun((r: number) => r + 1);
const onKeyDown = (e: KeyEvt) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
restart();
}
};
return (
<main className={styles.stage}>
<div key={run} className={styles.root + " " + styles.isDemo} style={vars}>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">{CPU_ICON}</span>
<span className={styles.name}>GODRICH BIOS</span>
<span className={styles.rev}>REV 2.4</span>
</div>
<div className={styles.scr} tabIndex={0} role="button" aria-label={SCR_ARIA}
onClick={restart} onKeyDown={onKeyDown}>
<div className={styles.logo} aria-hidden="true">
<span className={styles.lg1}>GODRICH</span>
<span className={styles.lg2}>{LOGO_SUB}</span>
</div>
<ul className={styles.lines}>
{ROWS.map((row, i) => (
<li key={i} className={styles.row + " " + styles["row" + (i + 1)]}>
<span className={styles.lab}>{row.lab}</span>
<span className={styles.res}>{row.res}</span>
</li>
))}
</ul>
</div>
<p className={styles.note}>{NOTE}</p>
</div>
</main>
);
}04 · 부팅 점검 줄 출력 — 측정 누적면적 9.339% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
@keyframes blLogo {
0% { clip-path: inset(0 100% 0 0); }
6%, 8% { clip-path: inset(0 0 0 0); }
14%, 75% { clip-path: inset(0 0 0 100%); }
76%, 88% { clip-path: inset(0 0 0 0); }
90%, 100% { clip-path: inset(0 100% 0 0); }
}
@keyframes blRow1 {
0%, 12% { clip-path: inset(0 100% 0 0); }
17%, 75% { clip-path: inset(0 0 0 0); }
76%, 100% { clip-path: inset(0 100% 0 0); }
}
점검 줄이 곧 로딩 문구인 판
화면을 누르거나 Enter·Space 로 아무 때나 다시 셀 수 있어, 로딩 시간을 이야기로 채우는 대기 화면에 그대로 얹습니다.
05두 번 눌러 여는 바탕화면 아이콘
한 번은 선택, 짧은 사이 두 번은 열기
한 번 누르면 이름표만 선택 색으로 바뀌고, 짧은 사이에 두 번 누르면 아이콘이 자기 중심에서 한 번 커졌다가 창이 열립니다. 두 번 누르기는 click 이벤트의 e.detail(같은 자리 연달아 누른 횟수)로 판정하는데, 터치에서는 이 값이 항상 1 이라 400ms 안에 같은 아이콘을 다시 눌렀는지로 대신 판정합니다.
<main class="stage">
<div class="dt is-demo" id="dt">
<div class="dt__icons" id="list" role="listbox" aria-label="바탕화면 아이콘" tabindex="0">
<button type="button" class="dt__ico dt__ico--0" role="option" aria-selected="false" tabindex="-1">
<span class="dt__glyph" 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="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="dt__label">내 문서</span>
</button>
<button type="button" class="dt__ico dt__ico--1" role="option" aria-selected="false" tabindex="-1">
<span class="dt__glyph" 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 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg></span>
<span class="dt__label">사진첩</span>
</button>
<button type="button" class="dt__ico dt__ico--2" role="option" aria-selected="false" tabindex="-1">
<span class="dt__glyph" 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="m230.1 108.76l-31.85-18.14c-.64-1.16-1.31-2.29-2-3.41l-.12-36A104.6 104.6 0 0 0 162 32l-32 17.89h-4L94 32a104.6 104.6 0 0 0-34.11 19.25l-.16 36c-.7 1.12-1.37 2.26-2 3.41l-31.84 18.1a99.2 99.2 0 0 0 0 38.46l31.85 18.14c.64 1.16 1.31 2.29 2 3.41l.12 36A104.6 104.6 0 0 0 94 224l32-17.87h4L162 224a104.6 104.6 0 0 0 34.08-19.25l.16-36c.7-1.12 1.37-2.26 2-3.41l31.84-18.1a99.2 99.2 0 0 0 .02-38.48M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m109.94-52.79a8 8 0 0 0-3.89-5.4l-29.83-17l-.12-33.62a8 8 0 0 0-2.83-6.08a111.9 111.9 0 0 0-36.72-20.67a8 8 0 0 0-6.46.59L128 41.85L97.88 25a8 8 0 0 0-6.47-.6a111.9 111.9 0 0 0-36.68 20.75a8 8 0 0 0-2.83 6.07l-.15 33.65l-29.83 17a8 8 0 0 0-3.89 5.4a106.5 106.5 0 0 0 0 41.56a8 8 0 0 0 3.89 5.4l29.83 17l.12 33.63a8 8 0 0 0 2.83 6.08a111.9 111.9 0 0 0 36.72 20.67a8 8 0 0 0 6.46-.59L128 214.15L158.12 231a7.9 7.9 0 0 0 3.9 1a8.1 8.1 0 0 0 2.57-.42a112.1 112.1 0 0 0 36.68-20.73a8 8 0 0 0 2.83-6.07l.15-33.65l29.83-17a8 8 0 0 0 3.89-5.4a106.5 106.5 0 0 0-.03-41.52m-15 34.91l-28.57 16.25a8 8 0 0 0-3 3c-.58 1-1.19 2.06-1.81 3.06a7.94 7.94 0 0 0-1.22 4.21l-.15 32.25a95.9 95.9 0 0 1-25.37 14.3L134 199.13a8 8 0 0 0-3.91-1h-3.83a8.1 8.1 0 0 0-4.1 1l-28.84 16.1A96 96 0 0 1 67.88 201l-.11-32.2a8 8 0 0 0-1.22-4.22c-.62-1-1.23-2-1.8-3.06a8.1 8.1 0 0 0-3-3.06l-28.6-16.29a90.5 90.5 0 0 1 0-28.26l28.52-16.28a8 8 0 0 0 3-3c.58-1 1.19-2.06 1.81-3.06a7.94 7.94 0 0 0 1.22-4.21l.15-32.25a95.9 95.9 0 0 1 25.37-14.3L122 56.87a8 8 0 0 0 4.1 1h3.64a8 8 0 0 0 4.1-1l28.84-16.1A96 96 0 0 1 188.12 55l.11 32.2a8 8 0 0 0 1.22 4.22c.62 1 1.23 2 1.8 3.06a8.1 8.1 0 0 0 3 3.06l28.6 16.29a90.5 90.5 0 0 1 .05 28.29Z"/></g></svg></span>
<span class="dt__label">설정</span>
</button>
</div>
<section class="dt__win" id="win" aria-hidden="true">
<header class="dt__tbar">
<span class="dt__tico" 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="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="dt__ttl" id="ttl">내 문서</span>
<button type="button" class="dt__x" id="x" aria-label="창 닫기"><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>
</header>
<p class="dt__body" id="wbody">문서 아이콘을 두 번 눌러 연 창입니다</p>
</section>
<p class="dt__hint">한 번 누르면 선택, 빠르게 두 번 누르면 열립니다. 방향키로도 옮깁니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 두 번 눌러 여는 바탕화면 아이콘 — 한 번 누르면 이름표만 선택 색으로 바뀌고, 짧은 사이에
// 두 번 누르면 아이콘이 자기 중심에서 한 번 커졌다가 창이 열린다. 방향키로도 선택이 옮겨 다닌다.
// 두 번 누르기 판정은 click 이벤트의 e.detail(같은 자리 연달아 누른 횟수) — 터치는 시간차로 보조한다.
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue-deep;
$cream: rgba(255, 247, 230, .72);
@include stage($stage-ink);
.dt {
position: relative; box-sizing: border-box;
width: min(100%, 432px); padding: $sp-3;
}
// ── 아이콘 세로줄
.dt__icons { display: flex; flex-direction: column; gap: $sp-3; width: max-content; }
.dt__ico {
display: flex; flex-direction: column; align-items: center; gap: $sp-1;
width: 80px; padding: $sp-1 0; margin: 0;
border: 0; border-radius: $r-xs; background: transparent; cursor: pointer;
font-family: inherit;
}
.dt__ico:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
// 아이콘 몸 — 커질 땐 반드시 자기 중심에서(규칙 8: 스스로 완결한 요소의 기본 원점)
.dt__glyph {
display: block; color: $subject-cream; font-size: 30px; line-height: 1;
transform-origin: 50% 50%;
}
.dt__label {
padding: 0 $sp-1; border-radius: $r-xs;
color: $subject-cream; font-weight: 700; font-size: 11px; line-height: 16px;
white-space: nowrap;
}
// 한 번 누르기 — 이름표만 선택 색으로
.dt__ico[aria-selected="true"] .dt__label {
background: $color; color: $stage-paper;
}
// 두 번 누르기 — 아이콘이 한 번 커졌다가 제자리로(원점은 중심 그대로)
.dt__ico.is-pop .dt__glyph {
animation-name: dtPopOnce;
animation-duration: 300ms; animation-timing-function: $easing;
animation-iteration-count: 1;
}
// ── 열리는 창
.dt__win {
position: absolute; top: $sp-3; right: $sp-3; width: min(58%, 236px);
border-radius: $r-card; background: $stage-paper; box-shadow: $shadow-raised;
opacity: 0; pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing;
}
.dt__win.is-open { opacity: 1; pointer-events: auto; }
.dt__tbar {
display: flex; align-items: center; gap: $sp-2;
padding: $sp-1 $sp-2; border-radius: $r-card $r-card 0 0;
background: $color; color: $stage-paper;
}
.dt__tico { display: grid; place-items: center; font-size: 13px; }
.dt__ttl { flex: 1 1 auto; font-weight: 700; font-size: 12px; line-height: 1.4; }
.dt__x {
display: grid; place-items: center; width: 20px; height: 20px; margin: 0;
border: 0; border-radius: $r-xs; background: rgba(255, 255, 255, .18);
color: $stage-paper; font-family: inherit; font-size: 11px; line-height: 1; cursor: pointer;
}
.dt__x:hover { background: rgba(255, 255, 255, .32); }
.dt__x:focus-visible { outline: 2px solid $stage-paper; outline-offset: 1px; }
.dt__body { margin: 0; padding: $sp-3; color: $subject-ink; font-weight: 500; font-size: 12px; line-height: 1.6; }
.dt__hint { margin: $sp-2 0 0; color: $cream; font-weight: 500; font-size: 11px; line-height: 1.4; }
// ── 격자 미리보기 루프(2초)
// 8% 첫 아이콘 이름표가 선택 색으로 컷, 16% 아이콘이 한 번 커졌다가, 22% 창이 컷으로 열리며
// 14px 아래에서 제자리로 정착한다. 60% 창이 닫히고 68% 방향키로 옮긴 듯 둘째 아이콘이 선택,
// 84% 선택이 풀려 처음 모습으로 돌아온다. 창이 열리는 프레임이 닫히는 프레임보다 앞이라
// poster 는 열린 창이 잡힌다.
.dt.is-demo .dt__ico--0 .dt__label {
animation-name: dtSelA;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.dt.is-demo .dt__ico--1 .dt__label {
animation-name: dtSelB;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.dt.is-demo .dt__ico--0 .dt__glyph {
animation-name: dtPopLoop;
animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.dt.is-demo .dt__win {
animation-name: dtWinO, dtWinY;
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;
}
@keyframes dtSelA {
0%, 7.9% { background: transparent; color: $subject-cream; }
8%, 59.9% { background: $color; color: $stage-paper; }
60%, 100% { background: transparent; color: $subject-cream; }
}
@keyframes dtSelB {
0%, 67% { background: transparent; color: $subject-cream; }
68%, 83% { background: $color; color: $stage-paper; }
84%, 100% { background: transparent; color: $subject-cream; }
}
@keyframes dtPopLoop {
0%, 16% { transform: scale(1); }
19% { transform: scale(1.16); }
23%, 100% { transform: scale(1); }
}
@keyframes dtPopOnce {
0% { transform: scale(1); }
40% { transform: scale(1.18); }
100% { transform: scale(1); }
}
@keyframes dtWinO {
0%, 21% { opacity: 0; }
22%, 59% { opacity: 1; }
60%, 100% { opacity: 0; }
}
@keyframes dtWinY {
0%, 22% { transform: translateY(14px); }
29%, 100% { transform: translateY(0); }
}
// ── 좁은 화면: 아이콘 줄을 가로로 눕히고 창을 아이콘 위에 겹쳐 연다.
// 세로 한 줄이면 아이콘 셋만으로 155px — 320×200 무대에 남는 높이가 없다(2026-09-13 실측).
@media (max-width: 340px) {
.dt { padding: $sp-2; }
.dt__icons { flex-direction: row; justify-content: center; width: 100%; gap: $sp-2; }
.dt__ico { width: 64px; padding: 0; }
.dt__glyph { font-size: 20px; }
.dt__label { font-size: 11px; line-height: 13px; }
.dt__win { top: $sp-2; right: $sp-2; left: $sp-2; width: auto; }
.dt__body { padding: $sp-2; font-size: 11px; }
.dt__hint { margin-top: $sp-1; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.dt.is-demo .dt__label, .dt.is-demo .dt__glyph, .dt.is-demo .dt__win {
animation: none !important;
transition: none !important;
}
// 루프를 끊으면 정적 모습 — 첫 아이콘이 선택돼 있고 창은 열려 있다(화면이 비지 않게)
.dt.is-demo .dt__ico--0 .dt__label { background: $color !important; color: $stage-paper !important; }
.dt.is-demo .dt__win { opacity: 1 !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);
}
.dt {
position: relative;
box-sizing: border-box;
width: min(100%, 432px);
padding: 12px;
}
.dt__icons {
display: flex;
flex-direction: column;
gap: 12px;
width: max-content;
}
.dt__ico {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 80px;
padding: 4px 0;
margin: 0;
border: 0;
border-radius: 4px;
background: transparent;
cursor: pointer;
font-family: inherit;
}
.dt__ico:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.dt__glyph {
display: block;
color: #fff7e6;
font-size: 30px;
line-height: 1;
transform-origin: 50% 50%;
}
.dt__label {
padding: 0 4px;
border-radius: 4px;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 16px;
white-space: nowrap;
}
.dt__ico[aria-selected=true] .dt__label {
background: #0a4fe8;
color: #ffffff;
}
.dt__ico.is-pop .dt__glyph {
animation-name: dtPopOnce;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
.dt__win {
position: absolute;
top: 12px;
right: 12px;
width: min(58%, 236px);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.dt__win.is-open {
opacity: 1;
pointer-events: auto;
}
.dt__tbar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 16px 16px 0 0;
background: #0a4fe8;
color: #ffffff;
}
.dt__tico {
display: grid;
place-items: center;
font-size: 13px;
}
.dt__ttl {
flex: 1 1 auto;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
}
.dt__x {
display: grid;
place-items: center;
width: 20px;
height: 20px;
margin: 0;
border: 0;
border-radius: 4px;
background: rgba(255, 255, 255, 0.18);
color: #ffffff;
font-family: inherit;
font-size: 11px;
line-height: 1;
cursor: pointer;
}
.dt__x:hover {
background: rgba(255, 255, 255, 0.32);
}
.dt__x:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 1px;
}
.dt__body {
margin: 0;
padding: 12px;
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.6;
}
.dt__hint {
margin: 8px 0 0;
color: rgba(255, 247, 230, 0.72);
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.dt.is-demo .dt__ico--0 .dt__label {
animation-name: dtSelA;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.dt.is-demo .dt__ico--1 .dt__label {
animation-name: dtSelB;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.dt.is-demo .dt__ico--0 .dt__glyph {
animation-name: dtPopLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.dt.is-demo .dt__win {
animation-name: dtWinO, dtWinY;
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;
transition: none;
}
@keyframes dtSelA {
0%, 7.9% {
background: transparent;
color: #fff7e6;
}
8%, 59.9% {
background: #0a4fe8;
color: #ffffff;
}
60%, 100% {
background: transparent;
color: #fff7e6;
}
}
@keyframes dtSelB {
0%, 67% {
background: transparent;
color: #fff7e6;
}
68%, 83% {
background: #0a4fe8;
color: #ffffff;
}
84%, 100% {
background: transparent;
color: #fff7e6;
}
}
@keyframes dtPopLoop {
0%, 16% {
transform: scale(1);
}
19% {
transform: scale(1.16);
}
23%, 100% {
transform: scale(1);
}
}
@keyframes dtPopOnce {
0% {
transform: scale(1);
}
40% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
@keyframes dtWinO {
0%, 21% {
opacity: 0;
}
22%, 59% {
opacity: 1;
}
60%, 100% {
opacity: 0;
}
}
@keyframes dtWinY {
0%, 22% {
transform: translateY(14px);
}
29%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.dt {
padding: 8px;
}
.dt__icons {
flex-direction: row;
justify-content: center;
width: 100%;
gap: 8px;
}
.dt__ico {
width: 64px;
padding: 0;
}
.dt__glyph {
font-size: 20px;
}
.dt__label {
font-size: 11px;
line-height: 13px;
}
.dt__win {
top: 8px;
right: 8px;
left: 8px;
width: auto;
}
.dt__body {
padding: 8px;
font-size: 11px;
}
.dt__hint {
margin-top: 4px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.dt.is-demo .dt__label, .dt.is-demo .dt__glyph, .dt.is-demo .dt__win {
animation: none !important;
transition: none !important;
}
.dt.is-demo .dt__ico--0 .dt__label {
background: #0a4fe8 !important;
color: #ffffff !important;
}
.dt.is-demo .dt__win {
opacity: 1 !important;
transform: none !important;
}
}var dt = document.getElementById('dt');
var list = document.getElementById('list');
var icons = [].slice.call(document.querySelectorAll('.dt__ico'));
var win = document.getElementById('win');
var ttl = document.getElementById('ttl');
var wbody = document.getElementById('wbody');
var lastTap = 0;
var lastIdx = -1;
var META = [
{ t: '내 문서', b: '문서 아이콘을 두 번 눌러 연 창입니다' },
{ t: '사진첩', b: '사진첩 아이콘을 두 번 눌러 연 창입니다' },
{ t: '설정', b: '설정 아이콘을 두 번 눌러 연 창입니다' }
];
function select(i, grab) {
icons.forEach(function (b, k) { b.setAttribute('aria-selected', k === i ? 'true' : 'false'); });
if (grab && icons[i]) icons[i].focus({ preventScroll: true });
}
function openWin(i) {
ttl.textContent = META[i].t;
wbody.textContent = META[i].b;
win.classList.add('is-open');
win.setAttribute('aria-hidden', 'false');
icons[i].classList.remove('is-pop');
void icons[i].offsetWidth;
icons[i].classList.add('is-pop');
}
function closeWin() {
win.classList.remove('is-open');
win.setAttribute('aria-hidden', 'true');
}
icons.forEach(function (b, i) {
b.addEventListener('click', function (e) {
dt.classList.remove('is-demo');
select(i, false);
// e.detail 은 같은 자리에서 연달아 누른 횟수 — 2 이상이면 두 번 누르기다.
// 터치는 detail 이 항상 1 이라 시간차(400ms)로 판정한다
if (e.detail >= 2 || (i === lastIdx && Date.now() - lastTap < 400)) openWin(i);
lastTap = Date.now();
lastIdx = i;
});
});
list.addEventListener('keydown', function (e) {
dt.classList.remove('is-demo');
var k = e.key;
var cur = -1;
icons.forEach(function (b, i) { if (b.getAttribute('aria-selected') === 'true') cur = i; });
if (k === 'ArrowDown' || k === 'ArrowRight') { e.preventDefault(); select((cur + 1) % icons.length, true); }
else if (k === 'ArrowUp' || k === 'ArrowLeft') { e.preventDefault(); select((cur - 1 + icons.length) % icons.length, true); }
else if (k === 'Enter' || k === ' ') { e.preventDefault(); if (cur < 0) cur = 0; select(cur, false); openWin(cur); }
else if (k === 'Escape') closeWin();
});
document.getElementById('x').addEventListener('click', closeWin);
dt.addEventListener('pointerdown', function (e) {
if (e.target === dt) { dt.classList.remove('is-demo'); select(-1, false); closeWin(); }
});// DesktopIcons — vanilla 판(run/023/demo/05-os-desktop-icon-doubleclick/index.html) 의 React 포트.
// 한 번 누르면 이름표만 선택 샅, 짧은 사이 두 번 누르면 아이콘이 한 번 커졌다가 창이 열린다.
// 두 번 누르기 판정은 click 이벤트의 e.detail — 터치는 시간차(400ms)로 보조한다.
// 방향키로 선택이 옮겨 다니고 Enter 로 연다.
import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./DesktopIcons.module.scss";
export type DesktopIconsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 타입에 React 이벤트 제네릭이 없다 — 쓰는 것만 로컬 타입으로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type ClickEvt = { detail: number; stopPropagation(): void };
type PtrEvt = { target: Node | null; currentTarget: Node | null };
type BtnBox = { current: Array<HTMLButtonElement | null> };
type NumBox = { current: number };
const LIST_ARIA = "바탕화면 아이콘";
const CLOSE_ARIA = "창 닫기";
const HINT = "한 번 누르면 선택, 빠르게 두 번 누르면 열립니다. 방향키로도 옮깁니다";
const META = [
{ icon: "folder", t: "내 문서", b: "문서 아이콘을 두 번 눌러 연 창입니다" },
{ icon: "image", t: "사진첩", b: "사진첩 아이콘을 두 번 눌러 연 창입니다" },
{ icon: "gear", t: "설정", b: "설정 아이콘을 두 번 눌러 연 창입니다" }
];
const ICONS: { [k: string]: ReactNode } = {
folder: (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" /><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" /></g></svg>
),
image: (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" /><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12" /></g></svg>
),
gear: (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m230.1 108.76l-31.85-18.14c-.64-1.16-1.31-2.29-2-3.41l-.12-36A104.6 104.6 0 0 0 162 32l-32 17.89h-4L94 32a104.6 104.6 0 0 0-34.11 19.25l-.16 36c-.7 1.12-1.37 2.26-2 3.41l-31.84 18.1a99.2 99.2 0 0 0 0 38.46l31.85 18.14c.64 1.16 1.31 2.29 2 3.41l.12 36A104.6 104.6 0 0 0 94 224l32-17.87h4L162 224a104.6 104.6 0 0 0 34.08-19.25l.16-36c.7-1.12 1.37-2.26 2-3.41l31.84-18.1a99.2 99.2 0 0 0 .02-38.48M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2" /><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m109.94-52.79a8 8 0 0 0-3.89-5.4l-29.83-17l-.12-33.62a8 8 0 0 0-2.83-6.08a111.9 111.9 0 0 0-36.72-20.67a8 8 0 0 0-6.46.59L128 41.85L97.88 25a8 8 0 0 0-6.47-.6a111.9 111.9 0 0 0-36.68 20.75a8 8 0 0 0-2.83 6.07l-.15 33.65l-29.83 17a8 8 0 0 0-3.89 5.4a106.5 106.5 0 0 0 0 41.56a8 8 0 0 0 3.89 5.4l29.83 17l.12 33.63a8 8 0 0 0 2.83 6.08a111.9 111.9 0 0 0 36.72 20.67a8 8 0 0 0 6.46-.59L128 214.15L158.12 231a7.9 7.9 0 0 0 3.9 1a8.1 8.1 0 0 0 2.57-.42a112.1 112.1 0 0 0 36.68-20.73a8 8 0 0 0 2.83-6.07l.15-33.65l29.83-17a8 8 0 0 0 3.89-5.4a106.5 106.5 0 0 0-.03-41.52m-15 34.91l-28.57 16.25a8 8 0 0 0-3 3c-.58 1-1.19 2.06-1.81 3.06a7.94 7.94 0 0 0-1.22 4.21l-.15 32.25a95.9 95.9 0 0 1-25.37 14.3L134 199.13a8 8 0 0 0-3.91-1h-3.83a8.1 8.1 0 0 0-4.1 1l-28.84 16.1A96 96 0 0 1 67.88 201l-.11-32.2a8 8 0 0 0-1.22-4.22c-.62-1-1.23-2-1.8-3.06a8.1 8.1 0 0 0-3-3.06l-28.6-16.29a90.5 90.5 0 0 1 0-28.26l28.52-16.28a8 8 0 0 0 3-3c.58-1 1.19-2.06 1.81-3.06a7.94 7.94 0 0 0 1.22-4.21l.15-32.25a95.9 95.9 0 0 1 25.37-14.3L122 56.87a8 8 0 0 0 4.1 1h3.64a8 8 0 0 0 4.1-1l28.84-16.1A96 96 0 0 1 188.12 55l.11 32.2a8 8 0 0 0 1.22 4.22c.62 1 1.23 2 1.8 3.06a8.1 8.1 0 0 0 3 3.06l28.6 16.29a90.5 90.5 0 0 1 .05 28.29Z" /></g></svg>
)
};
export default function DesktopIcons(props: DesktopIconsProps) {
const duration = props.duration ?? 220;
const easing = props.easing ?? "cubic-bezier(.2, 1.4, .4, 1)";
const color = props.color ?? "#0a4fe8";
const [preview, setPreview] = useState(true);
const [sel, setSel] = useState(-1);
const [winIdx, setWinIdx] = useState(-1);
const [pop, setPop] = useState({ idx: -1, key: 0 });
const btnBox = useRef<Array<HTMLButtonElement | null>>([]) as BtnBox;
const lastTap = useRef(0) as NumBox;
const lastIdx = useRef(-1) as NumBox;
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const select = (i: number, grab: boolean) => {
setSel(i);
if (grab && btnBox.current[i]) btnBox.current[i].focus({ preventScroll: true });
};
const openWin = (i: number) => {
setWinIdx(i);
setPop((p: { idx: number; key: number }) => ({ idx: i, key: p.key + 1 }));
};
const closeWin = () => setWinIdx(-1);
const onClick = (i: number, e: ClickEvt) => {
setPreview(false);
select(i, false);
// e.detail 은 같은 자리에서 연달아 누른 횟수 — 2 이상이면 두 번 누르기다
if (e.detail >= 2 || (i === lastIdx.current && Date.now() - lastTap.current < 400)) openWin(i);
lastTap.current = Date.now();
lastIdx.current = i;
};
const onKeyDown = (e: KeyEvt) => {
setPreview(false);
const k = e.key;
if (k === "ArrowDown" || k === "ArrowRight") {
e.preventDefault();
select((sel + 1) % META.length, true);
} else if (k === "ArrowUp" || k === "ArrowLeft") {
e.preventDefault();
select((sel - 1 + META.length) % META.length, true);
} else if (k === "Enter" || k === " ") {
e.preventDefault();
const cur = sel < 0 ? 0 : sel;
select(cur, false);
openWin(cur);
} else if (k === "Escape") {
closeWin();
}
};
return (
<main className={styles.stage}>
<div
className={styles.root + (preview ? " " + styles.isDemo : "")}
style={vars}
onPointerDown={(e: PtrEvt) => {
if (e.target === e.currentTarget) {
setPreview(false);
select(-1, false);
closeWin();
}
}}
>
<div className={styles.icons} role="listbox" aria-label={LIST_ARIA} tabIndex={0} onKeyDown={onKeyDown}>
{META.map((m, i) => (
<button
key={i}
type="button"
ref={(el: HTMLButtonElement | null) => { btnBox.current[i] = el; }}
className={styles.ico + " " + styles["i" + i]}
role="option"
aria-selected={sel === i}
tabIndex={-1}
onClick={(e: ClickEvt) => onClick(i, e)}
>
<span key={pop.idx === i ? pop.key : "idle"} className={styles.glyph + (pop.idx === i ? " " + styles.isPop : "")} aria-hidden="true">
{ICONS[m.icon]}
</span>
<span className={styles.label}>{m.t}</span>
</button>
))}
</div>
<section className={styles.win + (winIdx >= 0 ? " " + styles.isOpen : "")} aria-hidden={winIdx < 0}>
<header className={styles.tbar}>
<span className={styles.tico} aria-hidden="true">{ICONS.folder}</span>
<span className={styles.ttl}>{winIdx >= 0 ? META[winIdx].t : META[0].t}</span>
<button type="button" className={styles.x} aria-label={CLOSE_ARIA} onClick={closeWin}>
<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>
</header>
<p className={styles.body}>{winIdx >= 0 ? META[winIdx].b : META[0].b}</p>
</section>
<p className={styles.hint}>{HINT}</p>
</div>
</main>
);
}05 · 두 번 눌러 여는 바탕화면 아이콘 — 측정 누적면적 12.865% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
icons.forEach(function (b, i) {
b.addEventListener('click', function (e) {
dt.classList.remove('is-demo');
select(i, false);
// e.detail 은 같은 자리에서 연달아 누른 횟수 — 2 이상이면 두 번 누르기다.
// 터치는 detail 이 항상 1 이라 시간차(400ms)로 판정한다
if (e.detail >= 2 || (i === lastIdx && Date.now() - lastTap < 400)) openWin(i);
lastTap = Date.now();
lastIdx = i;
});
});
방향키로도 옮겨 다니는 아이콘 격자
방향키로 선택이 옮겨 다니고 aria-selected 로 상태가 읽혀, 항목을 아이콘 격자로 고르는 화면에 그대로 씁니다.
06시작 메뉴 펼침
아래에서 위로 열리고 옆으로 이어지는 메뉴
왼쪽 세로 띠(writing-mode 로 세로 쓰기)가 붙은 메뉴가 작업표시줄 위에서 아래변을 원점으로 열리고, 화살표가 있는 줄에 마우스를 올리면 하위 메뉴가 오른쪽으로 이어 열립니다. 하위 메뉴를 펼친 뒤 getBoundingClientRect 로 실측해 오른쪽이 화면 밖으로 나갈 방향이면 왼쪽으로 접어 넣고, 왼쪽마저 잘리면 원래 방향을 지킵니다.

<main class="stage">
<div class="sm is-demo" id="sm">
<p class="sm__hint">화살표가 있는 줄에 올리면 하위 메뉴가 오른쪽으로 이어 열리고, 그 방향이 화면을 나가면 왼쪽으로 접어 넣습니다</p>
<div class="sm__desk" aria-hidden="true">
<div class="sm__dwin">
<div class="sm__dttl">관리 콘솔</div>
<div class="sm__drow"></div>
<div class="sm__drow sm__drow--s"></div>
<div class="sm__drow sm__drow--m"></div>
</div>
</div>
<nav class="sm__menu" id="menu" aria-label="시작 메뉴">
<div class="sm__band" aria-hidden="true">GODRICH 98</div>
<div class="sm__rows">
<button type="button" class="sm__row" data-sub="0">
<span class="sm__rico" 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="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="sm__rtxt">프로그램</span>
<span class="sm__rarr" 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="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></span>
</button>
<button type="button" class="sm__row">
<span class="sm__rico" 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 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg></span>
<span class="sm__rtxt">문서</span>
</button>
<button type="button" class="sm__row" data-sub="1">
<span class="sm__rico" 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="m230.1 108.76l-31.85-18.14c-.64-1.16-1.31-2.29-2-3.41l-.12-36A104.6 104.6 0 0 0 162 32l-32 17.89h-4L94 32a104.6 104.6 0 0 0-34.11 19.25l-.16 36c-.7 1.12-1.37 2.26-2 3.41l-31.84 18.1a99.2 99.2 0 0 0 0 38.46l31.85 18.14c.64 1.16 1.31 2.29 2 3.41l.12 36A104.6 104.6 0 0 0 94 224l32-17.87h4L162 224a104.6 104.6 0 0 0 34.08-19.25l.16-36c.7-1.12 1.37-2.26 2-3.41l31.84-18.1a99.2 99.2 0 0 0 .02-38.48M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m109.94-52.79a8 8 0 0 0-3.89-5.4l-29.83-17l-.12-33.62a8 8 0 0 0-2.83-6.08a111.9 111.9 0 0 0-36.72-20.67a8 8 0 0 0-6.46.59L128 41.85L97.88 25a8 8 0 0 0-6.47-.6a111.9 111.9 0 0 0-36.68 20.75a8 8 0 0 0-2.83 6.07l-.15 33.65l-29.83 17a8 8 0 0 0-3.89 5.4a106.5 106.5 0 0 0 0 41.56a8 8 0 0 0 3.89 5.4l29.83 17l.12 33.63a8 8 0 0 0 2.83 6.08a111.9 111.9 0 0 0 36.72 20.67a8 8 0 0 0 6.46-.59L128 214.15L158.12 231a7.9 7.9 0 0 0 3.9 1a8.1 8.1 0 0 0 2.57-.42a112.1 112.1 0 0 0 36.68-20.73a8 8 0 0 0 2.83-6.07l.15-33.65l29.83-17a8 8 0 0 0 3.89-5.4a106.5 106.5 0 0 0-.03-41.52m-15 34.91l-28.57 16.25a8 8 0 0 0-3 3c-.58 1-1.19 2.06-1.81 3.06a7.94 7.94 0 0 0-1.22 4.21l-.15 32.25a95.9 95.9 0 0 1-25.37 14.3L134 199.13a8 8 0 0 0-3.91-1h-3.83a8.1 8.1 0 0 0-4.1 1l-28.84 16.1A96 96 0 0 1 67.88 201l-.11-32.2a8 8 0 0 0-1.22-4.22c-.62-1-1.23-2-1.8-3.06a8.1 8.1 0 0 0-3-3.06l-28.6-16.29a90.5 90.5 0 0 1 0-28.26l28.52-16.28a8 8 0 0 0 3-3c.58-1 1.19-2.06 1.81-3.06a7.94 7.94 0 0 0 1.22-4.21l.15-32.25a95.9 95.9 0 0 1 25.37-14.3L122 56.87a8 8 0 0 0 4.1 1h3.64a8 8 0 0 0 4.1-1l28.84-16.1A96 96 0 0 1 188.12 55l.11 32.2a8 8 0 0 0 1.22 4.22c.62 1 1.23 2 1.8 3.06a8.1 8.1 0 0 0 3 3.06l28.6 16.29a90.5 90.5 0 0 1 .05 28.29Z"/></g></svg></span>
<span class="sm__rtxt">설정</span>
<span class="sm__rarr" 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="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></span>
</button>
<div class="sm__sep" aria-hidden="true"></div>
<button type="button" class="sm__row sm__row--power">
<span class="sm__rico" 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 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg></span>
<span class="sm__rtxt">전원</span>
</button>
</div>
<div class="sm__sub sm__sub--1" id="sub0">
<button type="button" class="sm__sitem">메모장</button>
<button type="button" class="sm__sitem">그림판</button>
<button type="button" class="sm__sitem">계산기</button>
</div>
<div class="sm__sub sm__sub--2" id="sub1">
<button type="button" class="sm__sitem">디스플레이</button>
<button type="button" class="sm__sitem">사용자</button>
<button type="button" class="sm__sitem">네트워크</button>
</div>
</nav>
<div class="sm__bar">
<span class="sm__app" 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="M200 48H56a8 8 0 0 0-8 8v144a8 8 0 0 0 8 8h144a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8m-48 104h-48v-48h48Z" opacity=".2"/><path d="M152 96h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8m-8 48h-32v-32h32Zm88 0h-16v-32h16a8 8 0 0 0 0-16h-16V56a16 16 0 0 0-16-16h-40V24a8 8 0 0 0-16 0v16h-32V24a8 8 0 0 0-16 0v16H56a16 16 0 0 0-16 16v40H24a8 8 0 0 0 0 16h16v32H24a8 8 0 0 0 0 16h16v40a16 16 0 0 0 16 16h40v16a8 8 0 0 0 16 0v-16h32v16a8 8 0 0 0 16 0v-16h40a16 16 0 0 0 16-16v-40h16a8 8 0 0 0 0-16m-32 56H56V56h144z"/></g></svg></span>
<span class="sm__app" 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 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg></span>
<button type="button" class="sm__start" id="start" aria-expanded="false" aria-haspopup="true">
<span class="sm__sico" 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="M128 144h80v72l-80-14.55Zm-96 40l64 11.64V144H32Zm96-129.45V112h80V40ZM32 112h64V60.36L32 72Z" opacity=".2"/><path d="M208 136h-80a8 8 0 0 0-8 8v57.45a8 8 0 0 0 6.57 7.88l80 14.54a7.6 7.6 0 0 0 1.43.13a8 8 0 0 0 8-8v-72a8 8 0 0 0-8-8m-8 70.41l-64-11.63V152h64ZM96 136H32a8 8 0 0 0-8 8v40a8 8 0 0 0 6.57 7.87l64 11.64a8.5 8.5 0 0 0 1.43.13a8 8 0 0 0 8-8V144a8 8 0 0 0-8-8m-8 50.05l-48-8.73V152h48ZM213.13 33.86a8 8 0 0 0-6.56-1.73l-80 14.55a8 8 0 0 0-6.57 7.87V112a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8V40a8 8 0 0 0-2.87-6.14M200 104h-64V61.22l64-11.63Zm-98.87-49.78a8 8 0 0 0-6.56-1.73l-64 11.64A8 8 0 0 0 24 72v40a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8V60.36a8 8 0 0 0-2.87-6.14M88 104H40V78.68L88 70Z"/></g></svg></span>
<span class="sm__stxt">시작</span>
</button>
<span class="sm__grow" aria-hidden="true"></span>
<span class="sm__clock" aria-hidden="true">12:04</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 시작 메뉴 펼침 — 왼쪽 세로 띠(writing-mode 로 세로 글자)가 붙은 메뉴가 작업줄 위에서
// 아래에서 위로 열린다. 화살표가 있는 줄에 올리면 하위 메뉴가 오른쪽으로 이어 열리고,
// 펼친 뒤 실측해 오른쪽이 화면을 나갈 방향이면 왼쪽으로 접어 넣는다(JS 가 is-flip 을 단다).
// 미리보기 루프는 두 방향을 모두 보여준다: 첫 하위 메뉴는 오른쪽, 둘째는 왼쪽 접힘.
$duration: $dur-quick;
$easing: $ease-out;
$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);
$cream: rgba(255, 247, 230, .72);
@include stage($stage-yellow);
.sm {
--menu-w: 176px;
position: relative; box-sizing: border-box;
width: min(100%, 448px); height: 236px; padding: $sp-3;
border: 1px solid $edge; border-radius: $r-card;
background: $stage-paper; box-shadow: $shadow-raised;
}
// ── 뒤에 깔린 관리 화면(장식)과 안내 문구
.sm__desk { position: absolute; top: $sp-3; right: $sp-3; width: 180px; }
.sm__dwin { padding: $sp-2; border: 1px solid $edge; border-radius: $r-sm; background: $subject-cream; }
.sm__dttl { color: $subject-ink; font-weight: 700; font-size: 11px; line-height: 1.4; }
.sm__drow { height: 8px; margin-top: $sp-2; border-radius: $r-xs; background: rgba(23, 20, 26, .12); }
.sm__drow--s { width: 72%; }
.sm__drow--m { width: 48%; }
.sm__hint {
position: absolute; top: $sp-3; left: $sp-3; width: 192px; margin: 0;
color: $muted; font-weight: 500; font-size: 10px; line-height: 1.5;
}
// ── 메뉴 본체: 세로 띠 + 줄. 작업줄에 붙어 위로 열리므로 원점은 아래 변(규칙 8 예외: 붙는 지점)
.sm__menu {
position: absolute; left: 120px; bottom: 56px; width: var(--menu-w);
display: none; z-index: 3;
border: 1px solid $edge; border-radius: $r-sm;
background: $stage-paper; box-shadow: $shadow-raised;
transform-origin: 50% 100%;
}
.sm__menu.is-open { display: flex; }
// 실제로 열릴 때(사람 조작) — 아래 변을 원점으로 한 번 자라난다
.sm__menu.is-open {
animation-name: smMenuOnce;
animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: 1;
}
.sm__band {
display: grid; place-items: center; width: 28px;
padding: $sp-1 0; border-radius: $r-sm 0 0 $r-sm;
background: $color; color: $stage-paper;
writing-mode: vertical-rl;
font-weight: 700; font-size: 10px; line-height: 1.4; letter-spacing: 0.14em;
white-space: nowrap;
}
.sm__rows { display: flex; flex-direction: column; flex: 1 1 auto; padding: $sp-2; }
.sm__row {
display: flex; align-items: center; gap: $sp-2;
height: 32px; padding: 0 $sp-2; margin: 0;
border: 0; border-radius: $r-xs; background: transparent;
color: $subject-ink; font-family: inherit; font-weight: 500; font-size: 13px; line-height: 1;
cursor: pointer; text-align: left;
}
.sm__row:hover, .sm__row:focus-visible, .sm__row.is-hot { background: $color; color: $stage-paper; }
.sm__row:focus-visible { outline: 2px solid $subject-blue; outline-offset: -2px; }
.sm__rico { display: grid; place-items: center; font-size: 15px; }
.sm__rtxt { flex: 1 1 auto; }
.sm__rarr { display: grid; place-items: center; font-size: 12px; }
.sm__sep { height: 1px; margin: $sp-1 $sp-2; background: $edge; }
.sm__row--power { color: $error; }
.sm__row--power:hover, .sm__row--power:focus-visible, .sm__row--power.is-hot {
background: $error; color: $stage-paper;
}
// ── 하위 메뉴: 메뉴의 오른쪽 변에 붙는다(원점은 붙는 쪽 변 — 규칙 8 예외)
.sm__sub {
position: absolute; top: 6px; left: 100%; width: 104px;
display: none; padding: $sp-1;
border: 1px solid $edge; border-radius: $r-sm;
background: $stage-paper; box-shadow: $shadow-raised;
}
.sm__sub--1 { top: 6px; transform-origin: 0 50%; }
.sm__sub--2 { top: 64px; transform-origin: 100% 50%; }
.sm__sub.is-on { display: block; }
.sm__sub.is-on {
animation-name: smSubOnce;
animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: 1;
}
.sm__sub.is-flip { left: auto; right: 100%; }
.sm__sitem {
display: block; width: 100%; height: 28px; padding: 0 $sp-2; margin: 0;
border: 0; border-radius: $r-xs; background: transparent;
color: $subject-ink; font-family: inherit; font-weight: 500; font-size: 12px; line-height: 28px;
cursor: pointer; text-align: left; white-space: nowrap;
}
.sm__sitem:hover, .sm__sitem:focus-visible { background: $color; color: $stage-paper; }
.sm__sitem:focus-visible { outline: 2px solid $subject-blue; outline-offset: -2px; }
// ── 작업줄
.sm__bar {
position: absolute; left: $sp-3; right: $sp-3; bottom: $sp-3; height: 40px;
display: flex; align-items: center; gap: $sp-1; padding: 0 $sp-2;
border-radius: $r-xs; background: $subject-ink; z-index: 2;
}
.sm__app {
display: grid; place-items: center; width: 46px; height: 28px;
border-radius: $r-xs; color: $subject-cream; font-size: 14px;
}
.sm__start {
display: flex; align-items: center; gap: $sp-1;
height: 28px; padding: 0 $sp-3; margin: 0;
border: 0; border-radius: $r-xs; background: $color;
color: $stage-paper; font-family: inherit; font-weight: 700; font-size: 12px; line-height: 1;
cursor: pointer;
}
.sm__start:focus-visible { outline: 2px solid $stage-paper; outline-offset: 1px; }
.sm__sico { display: grid; place-items: center; font-size: 14px; }
.sm__grow { flex: 1 1 auto; }
.sm__clock {
padding-right: $sp-1; color: $cream;
font-family: $mono; font-weight: 500; font-size: 11px; line-height: 1;
}
// ── 격자 미리보기 루프(2초)
// 6% 시작 단추가 눌리고 메뉴가 아래에서 위로 열린다(12%). 18% 첫째 줄이 두드러지며 하위 메뉴가
// 오른쪽으로 이어 열리고, 46% 셋째 줄로 옮겨 하위 메뉴가 왼쪽으로 접혀 열린다(방향 전환 시연).
// 77% 메뉴가 아래로 접히고 83% 부터 닫힌 모습으로 쉰다.
.sm.is-demo .sm__menu {
display: flex;
animation-name: smMenuO;
animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.sm.is-demo .sm__sub { display: block; opacity: 0; }
.sm.is-demo .sm__sub--1 {
animation-name: smSub1O, smSub1X;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sm.is-demo .sm__sub--2 {
animation-name: smSub2O, smSub2F;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sm.is-demo .sm__rows .sm__row:nth-child(1) {
animation-name: smRowA;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.sm.is-demo .sm__rows .sm__row:nth-child(3) {
animation-name: smRowB;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.sm.is-demo .sm__start {
animation-name: smBtnY;
animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes smMenuO {
0%, 6% { clip-path: inset(100% 0 0 0); }
12%, 77% { clip-path: inset(0 0 0 0); }
83%, 100% { clip-path: inset(100% 0 0 0); }
}
@keyframes smMenuOnce {
0% { transform: scaleY(0.6); }
100% { transform: scaleY(1); }
}
@keyframes smRowA {
0%, 17.9% { background: transparent; color: $subject-ink; }
18%, 43.9% { background: $color; color: $stage-paper; }
44%, 100% { background: transparent; color: $subject-ink; }
}
@keyframes smRowB {
0%, 45.9% { background: transparent; color: $subject-ink; }
46%, 67.9% { background: $color; color: $stage-paper; }
68%, 100% { background: transparent; color: $subject-ink; }
}
@keyframes smSub1O {
0%, 17.9% { opacity: 0; clip-path: inset(0 100% 0 0); }
18%, 43.9% { opacity: 1; clip-path: inset(0 0 0 0); }
44%, 100% { opacity: 0; clip-path: inset(0 100% 0 0); }
}
@keyframes smSub1X {
0%, 18% { transform: translateX(-10px) scaleX(0.92); }
25%, 100% { transform: translateX(0) scaleX(1); }
}
// 둘째 하위 메뉴는 왼쪽 접힘 — 오른쪽 변(100% 50%) 을 원점으로 왼쪽으로 자라며,
// 이동량은 제 폭(100%) 에 메뉴 폭(var(--menu-w)) 을 더한 만큼이라 calc 로 묶는다
@keyframes smSub2O {
0%, 45.9% { opacity: 0; clip-path: inset(0 0 0 100%); }
46%, 67.9% { opacity: 1; clip-path: inset(0 0 0 0); }
68%, 100% { opacity: 0; clip-path: inset(0 0 0 100%); }
}
@keyframes smSub2F {
0%, 46% { transform: translateX(calc(-100% - var(--menu-w) + 12px)) scaleX(0.92); }
53%, 100% { transform: translateX(calc(-100% - var(--menu-w))) scaleX(1); }
}
@keyframes smBtnY {
0%, 5.9% { transform: translateY(0); }
6%, 8% { transform: translateY(1px); }
9%, 100% { transform: translateY(0); }
}
@keyframes smSubOnce {
0% { opacity: 0; clip-path: inset(0 100% 0 0); }
100% { opacity: 1; clip-path: inset(0 0 0 0); }
}
// ── 좁은 화면: 관리 화면 장식을 치우고 메뉴·하위 메뉴를 줄인다.
// 하위 메뉴 폭까지 합쳐 무대 안에 들어온다(시작 단추가 왼쪽 끝으로 붙으므로 오른쪽 펼침이 기본)
@media (max-width: 340px) {
.sm { --menu-w: 136px; height: 166px; padding: $sp-2; }
.sm__desk { display: none; }
.sm__hint { top: $sp-2; left: $sp-2; width: 150px; font-size: 9px; }
.sm__menu { left: 16px; bottom: 34px; }
.sm__row { height: 22px; gap: $sp-1; padding: 0 $sp-1; font-size: 11px; }
.sm__rico { font-size: 12px; }
.sm__rarr { font-size: 10px; }
.sm__band { width: 18px; font-size: 8px; }
.sm__sub { width: 84px; }
.sm__sub--2 { top: 44px; }
.sm__sitem { height: 22px; padding: 0 $sp-1; font-size: 10px; line-height: 22px; }
.sm__bar { left: $sp-2; right: $sp-2; bottom: $sp-2; height: 26px; padding: 0 $sp-1; }
.sm__app { display: none; }
.sm__start { height: 22px; padding: 0 $sp-2; font-size: 11px; }
.sm__sico { font-size: 12px; }
.sm__clock { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sm.is-demo .sm__menu, .sm.is-demo .sm__sub--1, .sm.is-demo .sm__sub--2,
.sm.is-demo .sm__rows .sm__row:nth-child(1), .sm.is-demo .sm__rows .sm__row:nth-child(3),
.sm.is-demo .sm__start {
animation: none !important;
}
// 루프를 끊으면 정적 모습 — 메뉴는 열린 채 머물고 하위 메뉴는 감춘다(화면이 비지 않게)
.sm.is-demo .sm__menu { clip-path: none !important; }
.sm.is-demo .sm__sub { opacity: 0 !important; clip-path: inset(0 100% 0 0) !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);
}
.sm {
--menu-w: 176px;
position: relative;
box-sizing: border-box;
width: min(100%, 448px);
height: 236px;
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sm__desk {
position: absolute;
top: 12px;
right: 12px;
width: 180px;
}
.sm__dwin {
padding: 8px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 8px;
background: #fff7e6;
}
.sm__dttl {
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
}
.sm__drow {
height: 8px;
margin-top: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
}
.sm__drow--s {
width: 72%;
}
.sm__drow--m {
width: 48%;
}
.sm__hint {
position: absolute;
top: 12px;
left: 12px;
width: 192px;
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-weight: 500;
font-size: 10px;
line-height: 1.5;
}
.sm__menu {
position: absolute;
left: 120px;
bottom: 56px;
width: var(--menu-w);
display: none;
z-index: 3;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: 50% 100%;
}
.sm__menu.is-open {
display: flex;
}
.sm__menu.is-open {
animation-name: smMenuOnce;
animation-duration: 220ms;
animation-timing-function: ease-out;
animation-iteration-count: 1;
}
.sm__band {
display: grid;
place-items: center;
width: 28px;
padding: 4px 0;
border-radius: 8px 0 0 8px;
background: #0a4fe8;
color: #ffffff;
writing-mode: vertical-rl;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
letter-spacing: 0.14em;
white-space: nowrap;
}
.sm__rows {
display: flex;
flex-direction: column;
flex: 1 1 auto;
padding: 8px;
}
.sm__row {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 8px;
margin: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: #17141a;
font-family: inherit;
font-weight: 500;
font-size: 13px;
line-height: 1;
cursor: pointer;
text-align: left;
}
.sm__row:hover, .sm__row:focus-visible, .sm__row.is-hot {
background: #0a4fe8;
color: #ffffff;
}
.sm__row:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.sm__rico {
display: grid;
place-items: center;
font-size: 15px;
}
.sm__rtxt {
flex: 1 1 auto;
}
.sm__rarr {
display: grid;
place-items: center;
font-size: 12px;
}
.sm__sep {
height: 1px;
margin: 4px 8px;
background: rgba(23, 20, 26, 0.14);
}
.sm__row--power {
color: #c2361a;
}
.sm__row--power:hover, .sm__row--power:focus-visible, .sm__row--power.is-hot {
background: #c2361a;
color: #ffffff;
}
.sm__sub {
position: absolute;
top: 6px;
left: 100%;
width: 104px;
display: none;
padding: 4px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sm__sub--1 {
top: 6px;
transform-origin: 0 50%;
}
.sm__sub--2 {
top: 64px;
transform-origin: 100% 50%;
}
.sm__sub.is-on {
display: block;
}
.sm__sub.is-on {
animation-name: smSubOnce;
animation-duration: 220ms;
animation-timing-function: ease-out;
animation-iteration-count: 1;
}
.sm__sub.is-flip {
left: auto;
right: 100%;
}
.sm__sitem {
display: block;
width: 100%;
height: 28px;
padding: 0 8px;
margin: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: #17141a;
font-family: inherit;
font-weight: 500;
font-size: 12px;
line-height: 28px;
cursor: pointer;
text-align: left;
white-space: nowrap;
}
.sm__sitem:hover, .sm__sitem:focus-visible {
background: #0a4fe8;
color: #ffffff;
}
.sm__sitem:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.sm__bar {
position: absolute;
left: 12px;
right: 12px;
bottom: 12px;
height: 40px;
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border-radius: 4px;
background: #17141a;
z-index: 2;
}
.sm__app {
display: grid;
place-items: center;
width: 46px;
height: 28px;
border-radius: 4px;
color: #fff7e6;
font-size: 14px;
}
.sm__start {
display: flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 12px;
margin: 0;
border: 0;
border-radius: 4px;
background: #0a4fe8;
color: #ffffff;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.sm__start:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 1px;
}
.sm__sico {
display: grid;
place-items: center;
font-size: 14px;
}
.sm__grow {
flex: 1 1 auto;
}
.sm__clock {
padding-right: 4px;
color: rgba(255, 247, 230, 0.72);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 11px;
line-height: 1;
}
.sm.is-demo .sm__menu {
display: flex;
animation-name: smMenuO;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sm.is-demo .sm__sub {
display: block;
opacity: 0;
}
.sm.is-demo .sm__sub--1 {
animation-name: smSub1O, smSub1X;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sm.is-demo .sm__sub--2 {
animation-name: smSub2O, smSub2F;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sm.is-demo .sm__rows .sm__row:nth-child(1) {
animation-name: smRowA;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sm.is-demo .sm__rows .sm__row:nth-child(3) {
animation-name: smRowB;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sm.is-demo .sm__start {
animation-name: smBtnY;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes smMenuO {
0%, 6% {
clip-path: inset(100% 0 0 0);
}
12%, 77% {
clip-path: inset(0 0 0 0);
}
83%, 100% {
clip-path: inset(100% 0 0 0);
}
}
@keyframes smMenuOnce {
0% {
transform: scaleY(0.6);
}
100% {
transform: scaleY(1);
}
}
@keyframes smRowA {
0%, 17.9% {
background: transparent;
color: #17141a;
}
18%, 43.9% {
background: #0a4fe8;
color: #ffffff;
}
44%, 100% {
background: transparent;
color: #17141a;
}
}
@keyframes smRowB {
0%, 45.9% {
background: transparent;
color: #17141a;
}
46%, 67.9% {
background: #0a4fe8;
color: #ffffff;
}
68%, 100% {
background: transparent;
color: #17141a;
}
}
@keyframes smSub1O {
0%, 17.9% {
opacity: 0;
clip-path: inset(0 100% 0 0);
}
18%, 43.9% {
opacity: 1;
clip-path: inset(0 0 0 0);
}
44%, 100% {
opacity: 0;
clip-path: inset(0 100% 0 0);
}
}
@keyframes smSub1X {
0%, 18% {
transform: translateX(-10px) scaleX(0.92);
}
25%, 100% {
transform: translateX(0) scaleX(1);
}
}
@keyframes smSub2O {
0%, 45.9% {
opacity: 0;
clip-path: inset(0 0 0 100%);
}
46%, 67.9% {
opacity: 1;
clip-path: inset(0 0 0 0);
}
68%, 100% {
opacity: 0;
clip-path: inset(0 0 0 100%);
}
}
@keyframes smSub2F {
0%, 46% {
transform: translateX(calc(-100% - var(--menu-w) + 12px)) scaleX(0.92);
}
53%, 100% {
transform: translateX(calc(-100% - var(--menu-w))) scaleX(1);
}
}
@keyframes smBtnY {
0%, 5.9% {
transform: translateY(0);
}
6%, 8% {
transform: translateY(1px);
}
9%, 100% {
transform: translateY(0);
}
}
@keyframes smSubOnce {
0% {
opacity: 0;
clip-path: inset(0 100% 0 0);
}
100% {
opacity: 1;
clip-path: inset(0 0 0 0);
}
}
@media (max-width: 340px) {
.sm {
--menu-w: 136px;
height: 166px;
padding: 8px;
}
.sm__desk {
display: none;
}
.sm__hint {
top: 8px;
left: 8px;
width: 150px;
font-size: 9px;
}
.sm__menu {
left: 16px;
bottom: 34px;
}
.sm__row {
height: 22px;
gap: 4px;
padding: 0 4px;
font-size: 11px;
}
.sm__rico {
font-size: 12px;
}
.sm__rarr {
font-size: 10px;
}
.sm__band {
width: 18px;
font-size: 8px;
}
.sm__sub {
width: 84px;
}
.sm__sub--2 {
top: 44px;
}
.sm__sitem {
height: 22px;
padding: 0 4px;
font-size: 10px;
line-height: 22px;
}
.sm__bar {
left: 8px;
right: 8px;
bottom: 8px;
height: 26px;
padding: 0 4px;
}
.sm__app {
display: none;
}
.sm__start {
height: 22px;
padding: 0 8px;
font-size: 11px;
}
.sm__sico {
font-size: 12px;
}
.sm__clock {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-demo .sm__menu, .sm.is-demo .sm__sub--1, .sm.is-demo .sm__sub--2,
.sm.is-demo .sm__rows .sm__row:nth-child(1), .sm.is-demo .sm__rows .sm__row:nth-child(3),
.sm.is-demo .sm__start {
animation: none !important;
}
.sm.is-demo .sm__menu {
clip-path: none !important;
}
.sm.is-demo .sm__sub {
opacity: 0 !important;
clip-path: inset(0 100% 0 0) !important;
}
}var sm = document.getElementById('sm');
var btn = document.getElementById('start');
var menu = document.getElementById('menu');
var rows = [].slice.call(document.querySelectorAll('.sm__row'));
var subs = [].slice.call(document.querySelectorAll('.sm__sub'));
function hideSubs() {
subs.forEach(function (s) { s.classList.remove('is-on', 'is-flip'); });
}
function setOpen(on) {
menu.classList.toggle('is-open', on);
btn.setAttribute('aria-expanded', on ? 'true' : 'false');
if (!on) hideSubs();
}
// 하위 메뉴를 펼친 뒤 실측해 오른쪽이 화면 밖으로 나가면 왼쪽으로 접어 넣는다.
// 왼쪽마저 잘리면 원래 방향을 지킨다(양쪽 다 못 채우는 좁이는 없다고 본다)
function showSub(sub) {
hideSubs();
sub.classList.add('is-on');
var r = sub.getBoundingClientRect();
if (r.right > window.innerWidth - 8) {
sub.classList.add('is-flip');
if (sub.getBoundingClientRect().left < 8) sub.classList.remove('is-flip');
}
}
function touch() { sm.classList.remove('is-demo'); }
btn.addEventListener('click', function () { touch(); setOpen(!menu.classList.contains('is-open')); });
rows.forEach(function (row) {
var idx = row.getAttribute('data-sub');
var sub = idx ? subs[+idx] : null;
function activate() {
if (!menu.classList.contains('is-open')) return;
if (sub) showSub(sub); else hideSubs();
}
row.addEventListener('pointerenter', activate);
row.addEventListener('focus', activate);
row.addEventListener('click', function (e) {
touch();
if (!menu.classList.contains('is-open')) setOpen(true);
activate();
e.stopPropagation();
});
});
menu.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
var k = 0;
rows.forEach(function (r, i) { if (document.activeElement === r) k = i; });
var n = (k + (e.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length;
rows[n].focus({ preventScroll: true });
});
document.addEventListener('click', function (e) { if (!sm.contains(e.target)) setOpen(false); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') setOpen(false); });// StartMenu — vanilla 판(run/023/demo/06-os-start-menu-accordion/index.html) 의 React 포트.
// 세로 띠가 붙은 메뉴가 작업줄 위에서 아래로 열린다. 화살표 줄에 올리면 하위 메뉴가 오른쪽으로,
// 펼친 뒤 실측해 오른쪽이 화면을 나갈 방향이면 왼쪽으로 접어 넣는다(useEffect 에서 재 측정).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./StartMenu.module.scss";
export type StartMenuProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 타입에 React 이벤트 제네릭이 없다 — 쓰는 것만 로컬 타입으로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type ClickEvt = { stopPropagation(): void };
type SubBox = { current: Array<HTMLDivElement | null> };
type RootBox = { current: HTMLDivElement | null };
const MENU_ARIA = "시작 메뉴";
const START = "시작";
const HINT = "화살표가 있는 줄에 올리면 하위 메뉴가 오른쪽으로 이어 열리고, 그 방향이 화면을 나가면 왼쪽으로 접어 넣습니다";
const CONSOLE = "관리 콘솔";
// 줄: 화살표가 있으면 sub 인덱스, 없으면 null
const ROWS: Array<{ icon: string; t: string; sub: number | null }> = [
{ icon: "folder", t: "프로그램", sub: 0 },
{ icon: "image", t: "문서", sub: null },
{ icon: "gear", t: "설정", sub: 1 },
{ icon: "power", t: "전원", sub: null }
];
const SUBS: Array<Array<string>> = [
["메모장", "그림판", "계산기"],
["디스플레이", "사용자", "네트워크"]
];
const SVG: { [k: string]: ReactNode } = {
folder: <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" /><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" /></g></svg>,
image: <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" /><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12" /></g></svg>,
gear: <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m230.1 108.76l-31.85-18.14c-.64-1.16-1.31-2.29-2-3.41l-.12-36A104.6 104.6 0 0 0 162 32l-32 17.89h-4L94 32a104.6 104.6 0 0 0-34.11 19.25l-.16 36c-.7 1.12-1.37 2.26-2 3.41l-31.84 18.1a99.2 99.2 0 0 0 0 38.46l31.85 18.14c.64 1.16 1.31 2.29 2 3.41l.12 36A104.6 104.6 0 0 0 94 224l32-17.87h4L162 224a104.6 104.6 0 0 0 34.08-19.25l.16-36c.7-1.12 1.37-2.26 2-3.41l31.84-18.1a99.2 99.2 0 0 0 .02-38.48M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2" /><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m109.94-52.79a8 8 0 0 0-3.89-5.4l-29.83-17l-.12-33.62a8 8 0 0 0-2.83-6.08a111.9 111.9 0 0 0-36.72-20.67a8 8 0 0 0-6.46.59L128 41.85L97.88 25a8 8 0 0 0-6.47-.6a111.9 111.9 0 0 0-36.68 20.75a8 8 0 0 0-2.83 6.07l-.15 33.65l-29.83 17a8 8 0 0 0-3.89 5.4a106.5 106.5 0 0 0 0 41.56a8 8 0 0 0 3.89 5.4l29.83 17l.12 33.63a8 8 0 0 0 2.83 6.08a111.9 111.9 0 0 0 36.72 20.67a8 8 0 0 0 6.46-.59L128 214.15L158.12 231a7.9 7.9 0 0 0 3.9 1a8.1 8.1 0 0 0 2.57-.42a112.1 112.1 0 0 0 36.68-20.73a8 8 0 0 0 2.83-6.07l.15-33.65l29.83-17a8 8 0 0 0 3.89-5.4a106.5 106.5 0 0 0-.03-41.52m-15 34.91l-28.57 16.25a8 8 0 0 0-3 3c-.58 1-1.19 2.06-1.81 3.06a7.94 7.94 0 0 0-1.22 4.21l-.15 32.25a95.9 95.9 0 0 1-25.37 14.3L134 199.13a8 8 0 0 0-3.91-1h-3.83a8.1 8.1 0 0 0-4.1 1l-28.84 16.1A96 96 0 0 1 67.88 201l-.11-32.2a8 8 0 0 0-1.22-4.22c-.62-1-1.23-2-1.8-3.06a8.1 8.1 0 0 0-3-3.06l-28.6-16.29a90.5 90.5 0 0 1 0-28.26l28.52-16.28a8 8 0 0 0 3-3c.58-1 1.19-2.06 1.81-3.06a7.94 7.94 0 0 0 1.22-4.21l.15-32.25a95.9 95.9 0 0 1 25.37-14.3L122 56.87a8 8 0 0 0 4.1 1h3.64a8 8 0 0 0 4.1-1l28.84-16.1A96 96 0 0 1 188.12 55l.11 32.2a8 8 0 0 0 1.22 4.22c.62 1 1.23 2 1.8 3.06a8.1 8.1 0 0 0 3 3.06l28.6 16.29a90.5 90.5 0 0 1 .05 28.29Z" /></g></svg>,
power: <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" /><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7" /></g></svg>,
cpu: <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 48H56a8 8 0 0 0-8 8v144a8 8 0 0 0 8 8h144a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8m-48 104h-48v-48h48Z" opacity=".2" /><path d="M152 96h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8m-8 48h-32v-32h32Zm88 0h-16v-32h16a8 8 0 0 0 0-16h-16V56a16 16 0 0 0-16-16h-40V24a8 8 0 0 0-16 0v16h-32V24a8 8 0 0 0-16 0v16H56a16 16 0 0 0-16 16v40H24a8 8 0 0 0 0 16h16v32H24a8 8 0 0 0 0 16h16v40a16 16 0 0 0 16 16h40v16a8 8 0 0 0 16 0v-16h32v16a8 8 0 0 0 16 0v-16h40a16 16 0 0 0 16-16v-40h16a8 8 0 0 0 0-16m-32 56H56V56h144z" /></g></svg>,
user: <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2" /><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56" /></g></svg>,
win: <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M128 144h80v72l-80-14.55Zm-96 40l64 11.64V144H32Zm96-129.45V112h80V40ZM32 112h64V60.36L32 72Z" opacity=".2" /><path d="M208 136h-80a8 8 0 0 0-8 8v57.45a8 8 0 0 0 6.57 7.88l80 14.54a7.6 7.6 0 0 0 1.43.13a8 8 0 0 0 8-8v-72a8 8 0 0 0-8-8m-8 70.41l-64-11.63V152h64ZM96 136H32a8 8 0 0 0-8 8v40a8 8 0 0 0 6.57 7.87l64 11.64a8.5 8.5 0 0 0 1.43.13a8 8 0 0 0 8-8V144a8 8 0 0 0-8-8m-8 50.05l-48-8.73V152h48ZM213.13 33.86a8 8 0 0 0-6.56-1.73l-80 14.55a8 8 0 0 0-6.57 7.87V112a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8V40a8 8 0 0 0-2.87-6.14M200 104h-64V61.22l64-11.63Zm-98.87-49.78a8 8 0 0 0-6.56-1.73l-64 11.64A8 8 0 0 0 24 72v40a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8V60.36a8 8 0 0 0-2.87-6.14M88 104H40V78.68L88 70Z" /></g></svg>,
caret: <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>
};
export default function StartMenu(props: StartMenuProps) {
const duration = props.duration ?? 300;
const easing = props.easing ?? "ease-out";
const color = props.color ?? "#0a4fe8";
const [preview, setPreview] = useState(true);
const [open, setOpen] = useState(false);
const [subOn, setSubOn] = useState<number | null>(null);
const [flip, setFlip] = useState(false);
const subBox = useRef<Array<HTMLDivElement | null>>([]) as SubBox;
const rootBox = useRef<HTMLDivElement>(null) as RootBox;
// 하위 메뉴를 펼친 뒤 실측해 오른쪽이 화면 밖으로 나가면 왼쪽으로 접어 넣는다
useEffect(() => {
if (subOn === null) return;
const el = subBox.current[subOn];
if (!el) return;
if (el.getBoundingClientRect().right > window.innerWidth - 8) setFlip(true);
else setFlip(false);
}, [subOn]);
// 왼쪽마저 잘리면 원래 방향을 지킨다
useEffect(() => {
if (!flip || subOn === null) return;
const el = subBox.current[subOn];
if (el && el.getBoundingClientRect().left < 8) setFlip(false);
}, [flip]);
// 바깥 클릭과 Esc 로 닫는다
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (rootBox.current && e.target instanceof Node && !rootBox.current.contains(e.target)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
document.addEventListener("click", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("click", onDoc);
document.removeEventListener("keydown", onKey);
};
}, []);
const activate = (rowIdx: number) => {
if (!open) return;
const sub = ROWS[rowIdx].sub;
if (sub === null) setSubOn(null);
else setSubOn(sub);
};
const toggleMenu = () => {
setPreview(false);
const next = !open;
setOpen(next);
if (!next) setSubOn(null);
};
const onRowKey = (e: KeyEvt) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
let k = 0;
rowBtns.forEach((b, i) => { if (b === document.activeElement) k = i; });
const n = (k + (e.key === "ArrowDown" ? 1 : -1) + rowBtns.length) % rowBtns.length;
if (rowBtns[n]) rowBtns[n].focus({ preventScroll: true });
};
// 줄 단추들을 담아 두는 배열 — roving 초점용
const rowBtns: Array<HTMLButtonElement | null> = [];
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div ref={rootBox} className={styles.root + (preview ? " " + styles.isDemo : "")} style={vars}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.desk} aria-hidden="true">
<div className={styles.dwin}>
<div className={styles.dttl}>{CONSOLE}</div>
<div className={styles.drow}></div>
<div className={styles.drow + " " + styles.drowS}></div>
<div className={styles.drow + " " + styles.drowM}></div>
</div>
</div>
<nav className={styles.menu + (open ? " " + styles.isOpen : "")} aria-label={MENU_ARIA}>
<div className={styles.band} aria-hidden="true">GODRICH 98</div>
<div className={styles.rows} onKeyDown={onRowKey}>
{ROWS.map((row, i) => [
i === ROWS.length - 1 ? (
<div key={"sep" + i} className={styles.sep} aria-hidden="true"></div>
) : null,
<button
key={i}
type="button"
ref={(el: HTMLButtonElement | null) => { rowBtns[i] = el; }}
className={styles.row + (row.icon === "power" ? " " + styles.power : "")}
onClick={(e: ClickEvt) => {
setPreview(false);
if (!open) setOpen(true);
const sub = ROWS[i].sub;
if (sub === null) setSubOn(null); else setSubOn(sub);
e.stopPropagation();
}}
onPointerEnter={() => activate(i)}
onFocus={() => activate(i)}
>
<span className={styles.rico} aria-hidden="true">{SVG[row.icon]}</span>
<span className={styles.rtxt}>{row.t}</span>
{row.sub !== null ? <span className={styles.rarr} aria-hidden="true">{SVG.caret}</span> : null}
</button>
])}
</div>
{SUBS.map((items, i) => (
<div
key={i}
ref={(el: HTMLDivElement | null) => { subBox.current[i] = el; }}
className={styles.sub + " " + styles["sub" + (i + 1)] + (subOn === i ? " " + styles.on : "") + (subOn === i && flip ? " " + styles.flipped : "")}
>
{items.map((t, j) => (
<button key={j} type="button" className={styles.sitem}>{t}</button>
))}
</div>
))}
</nav>
<div className={styles.bar}>
<span className={styles.app} aria-hidden="true">{SVG.cpu}</span>
<span className={styles.app} aria-hidden="true">{SVG.user}</span>
<button
type="button"
className={styles.start}
aria-expanded={open}
aria-haspopup="true"
onClick={toggleMenu}
>
<span className={styles.sico} aria-hidden="true">{SVG.win}</span>
<span className={styles.stxt}>{START}</span>
</button>
<span className={styles.grow} aria-hidden="true"></span>
<span className={styles.clock} aria-hidden="true">12:04</span>
</div>
</div>
</main>
);
}06 · 시작 메뉴 펼침 — 측정 누적면적 11.544% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 하위 메뉴를 펼친 뒤 실측해 오른쪽이 화면 밖으로 나가면 왼쪽으로 접어 넣는다.
// 왼쪽마저 잘리면 원래 방향을 지킨다(양쪽 다 못 채우는 좁이는 없다고 본다)
function showSub(sub) {
hideSubs();
sub.classList.add('is-on');
var r = sub.getBoundingClientRect();
if (r.right > window.innerWidth - 8) {
sub.classList.add('is-flip');
if (sub.getBoundingClientRect().left < 8) sub.classList.remove('is-flip');
}
}
접힘 방향까지 스스로 고르는 구조
하위 메뉴가 화면 밖으로 새지 않게 방향을 스스로 고르니, 항목이 많은 관리 화면의 메뉴에 얹어도 좁은 창에서 무너지지 않습니다.
07작업표시줄 시계와 알림 칸
실제 시각을 분 단위로 세는 시계
시계는 toLocaleTimeString 이 페이지 언어로 실제 시각을 분 단위로 찍고 5초마다 다시 잽니다. 창 단추는 눌림과 올라옴에서 border-color 의 밝은 면과 어두운 면 방향이 서로 반대로 뒤집히고 translateY(1px) 로 한 칸 내려앉아, 그림 없이도 오목·볼록이 읽힙니다.

<main class="stage">
<div class="tb is-demo" id="tb">
<div class="tb__desk">
<div class="tb__win">
<div class="tb__title">
<span class="tb__tico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M128 144h80v72l-80-14.55Zm-96 40l64 11.64V144H32Zm96-129.45V112h80V40ZM32 112h64V60.36L32 72Z" opacity=".2"/><path d="M208 136h-80a8 8 0 0 0-8 8v57.45a8 8 0 0 0 6.57 7.88l80 14.54a7.6 7.6 0 0 0 1.43.13a8 8 0 0 0 8-8v-72a8 8 0 0 0-8-8m-8 70.41l-64-11.63V152h64ZM96 136H32a8 8 0 0 0-8 8v40a8 8 0 0 0 6.57 7.87l64 11.64a8.5 8.5 0 0 0 1.43.13a8 8 0 0 0 8-8V144a8 8 0 0 0-8-8m-8 50.05l-48-8.73V152h48ZM213.13 33.86a8 8 0 0 0-6.56-1.73l-80 14.55a8 8 0 0 0-6.57 7.87V112a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8V40a8 8 0 0 0-2.87-6.14M200 104h-64V61.22l64-11.63Zm-98.87-49.78a8 8 0 0 0-6.56-1.73l-64 11.64A8 8 0 0 0 24 72v40a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8V60.36a8 8 0 0 0-2.87-6.14M88 104H40V78.68L88 70Z"/></g></svg></span>
<span class="tb__tname">메모장</span>
<span class="tb__tbtns" aria-hidden="true"><span class="tb__gb tb__gb--min"></span><span class="tb__gb"></span></span>
</div>
<div class="tb__body">
<p class="tb__line">하단 바의 시계는 지금 시각을 분 단위로 셉니다</p>
<p class="tb__line tb__line--mute">종 모양 알림을 누르면 말풍선이 뜹니다</p>
</div>
</div>
</div>
<div class="tb__bar">
<span class="tb__start" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 256 256"><g fill="currentColor"><path d="M128 144h80v72l-80-14.55Zm-96 40l64 11.64V144H32Zm96-129.45V112h80V40ZM32 112h64V60.36L32 72Z" opacity=".2"/><path d="M208 136h-80a8 8 0 0 0-8 8v57.45a8 8 0 0 0 6.57 7.88l80 14.54a7.6 7.6 0 0 0 1.43.13a8 8 0 0 0 8-8v-72a8 8 0 0 0-8-8m-8 70.41l-64-11.63V152h64ZM96 136H32a8 8 0 0 0-8 8v40a8 8 0 0 0 6.57 7.87l64 11.64a8.5 8.5 0 0 0 1.43.13a8 8 0 0 0 8-8V144a8 8 0 0 0-8-8m-8 50.05l-48-8.73V152h48ZM213.13 33.86a8 8 0 0 0-6.56-1.73l-80 14.55a8 8 0 0 0-6.57 7.87V112a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8V40a8 8 0 0 0-2.87-6.14M200 104h-64V61.22l64-11.63Zm-98.87-49.78a8 8 0 0 0-6.56-1.73l-64 11.64A8 8 0 0 0 24 72v40a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8V60.36a8 8 0 0 0-2.87-6.14M88 104H40V78.68L88 70Z"/></g></svg></span>
<button type="button" class="tb__task" id="task1" aria-pressed="true">메모장</button>
<button type="button" class="tb__task" id="task2" aria-pressed="false">그림판</button>
<div class="tb__tray">
<span class="tb__spk" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58M248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128"/></g></svg></span>
<button type="button" class="tb__bell" id="bell" aria-pressed="false" aria-label="알림"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg></button>
<span class="tb__clock">
<span class="tb__clockreal" id="clockreal">오후 9:41</span>
<span class="tb__clockpre" aria-hidden="true"><span class="tb__ck tb__ck--1">오후 9:41</span><span class="tb__ck tb__ck--2">오후 9:42</span></span>
</span>
<span class="tb__balloon">업데이트가 준비되었습니다</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 작업표시줄 시계와 알림 칸 — 오른쪽 끝 시계가 toLocaleTimeString 으로 실제 시각을 분 단위로 세고,
// 종 모양 알림 칸을 누르면 그 위로 말풍선이 오른다. 창 단추는 눌림(어두운 면이 위·왼쪽)과
// 올라옴(밝은 면이 위·왼쪽)에서 border-color 의 밝은 면과 어두운 면 방향이 서로 반대로 뒤집힌다.
// 격자 미리보기(.is-demo)는 종이 켜지고 말풍선이 떴다 지는 2초 루프다.
$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, .38);
$lite: rgba(255, 255, 255, .95);
$muted: rgba(23, 20, 26, .72);
@include stage($stage-orange);
// ── 화면 패널: 먹 바탕 화면 + 크림 작업표시줄
.tb {
position: relative; display: flex; flex-direction: column;
box-sizing: border-box; width: min(100%, 392px);
height: 204px; max-height: calc(100vh - 32px);
border: 2px solid; border-color: $lite $edge $edge $lite;
background: $subject-ink;
}
.tb button { font-family: inherit; cursor: pointer; }
.tb button:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
// ── 화면 안의 창
.tb__desk { flex: 1 1 auto; display: grid; place-items: center; padding: $sp-2; min-height: 0; }
.tb__win { width: min(100%, 236px); }
.tb__title {
display: flex; align-items: center; gap: $sp-1;
height: 24px; padding: 0 $sp-1; box-sizing: border-box;
background: $color; color: $subject-cream;
}
.tb__tico { display: grid; place-items: center; }
.tb__tname { flex: 1 1 auto; font-weight: 700; font-size: 12px; line-height: 1.4; white-space: nowrap; overflow: hidden; }
// 제목줄 장식 글리프 — 밑줄(최소화)과 네모(최대화). 누르면 변하는 게 아니므로 장식이다
.tb__tbtns { display: flex; align-items: center; gap: $sp-1; }
.tb__gb { width: 14px; height: 13px; box-sizing: border-box; border: 2px solid rgba(255, 247, 230, .85); }
.tb__gb--min { border: 0; border-bottom: 2px solid rgba(255, 247, 230, .85); width: 12px; align-self: flex-end; }
.tb__body {
padding: $sp-2;
border: 2px solid; border-color: $lite $edge $edge $lite;
background: $subject-cream;
}
.tb__line { margin: 0; font-weight: 500; font-size: 11px; line-height: 1.45; color: $subject-ink; }
.tb__line + .tb__line { margin-top: $sp-1; }
.tb__line--mute { color: $muted; }
// ── 작업표시줄
.tb__bar {
display: flex; align-items: center; gap: $sp-1;
height: 34px; padding: 0 $sp-1; box-sizing: border-box;
background: $subject-cream; border-top: 1px solid rgba(23, 20, 26, .3);
}
.tb__start { display: grid; place-items: center; width: 26px; height: 26px; color: $subject-ink; }
// 창 단추 — 올라온 상태는 밝은 면이 위·왼쪽
.tb__task {
height: 26px; padding: 0 $sp-2; box-sizing: border-box;
background: $subject-cream; color: $subject-ink;
border: 2px solid; border-color: $lite $edge $edge $lite;
font-weight: 500; font-size: 11px; line-height: 1.4; white-space: nowrap;
transition: transform $duration $ease-out, background-color $duration $ease-out;
}
// 눌린 상태는 border-color 의 밝은 면과 어두운 면이 반대 방향
.tb__task[aria-pressed="true"] {
border-color: $edge $lite $lite $edge;
background: rgba(23, 20, 26, .07);
font-weight: 700; transform: translateY(1px);
}
// ── 트레이 — 눌려 들어간 칸에 소리·알림·시계가 산다
.tb__tray {
position: relative; display: flex; align-items: center; gap: $sp-1;
margin-left: auto; height: 26px; padding: 0 $sp-1; box-sizing: border-box;
border: 2px solid; border-color: $edge $lite $lite $edge;
background: rgba(23, 20, 26, .05);
}
.tb__spk { display: grid; place-items: center; color: $subject-ink; }
.tb__bell {
display: grid; place-items: center; padding: 0; border: 0; background: none;
color: $subject-ink; transition: color $duration $ease-out;
}
.tb__bell[aria-pressed="true"] { color: $subject-orange-deep; }
// 시계 — 실제 값은 JS 가 채우고, 미리보기에서는 겹켜진 값 장이 컷으로 갈아 끼운다
.tb__clock { display: grid; justify-items: center; min-width: 62px; }
.tb__clock > * { grid-area: 1 / 1; }
.tb__clockreal, .tb__ck {
font-family: $mono; font-weight: 700; font-size: 12px; line-height: 1.4;
color: $subject-ink; white-space: nowrap;
}
.tb__clockpre { display: grid; justify-items: center; }
.tb__ck { opacity: 0; }
// 말풍선 — 트레이 위로 오른다. 종이 켜지면(via 형제 선택자) 보인다
.tb__balloon {
position: absolute; inset: auto 0 calc(100% + #{$sp-1}) auto;
box-sizing: border-box; padding: $sp-1 $sp-2;
background: $stage-paper; border: 1px solid $subject-ink; border-radius: $r-sm;
box-shadow: $shadow-press;
font-weight: 600; font-size: 11px; line-height: 1.4; color: $subject-ink; white-space: nowrap;
opacity: 0; visibility: hidden; pointer-events: none;
transform: translateY(5px) scale(.94);
transition: opacity $duration $easing, transform $duration $easing, visibility $duration;
}
.tb__bell[aria-pressed="true"] ~ .tb__balloon { opacity: 1; visibility: visible; transform: none; }
.tb__balloon::after {
content: ""; position: absolute; right: 20px; bottom: -4px;
width: 8px; height: 8px; background: $stage-paper;
border-right: 1px solid $subject-ink; border-bottom: 1px solid $subject-ink;
transform: rotate(45deg);
}
// ── 격자 미리보기 루프 (2초): 16% 에 종이 켜지며 말풍선이 뜨고, 40% 에 창 단추 두 개의
// 눌림이 서로 맞바뀌고, 76% 에 말풍선이 지고 80% 에 종이 꺼진 뒤 90% 안에 처음으로 돌아온다.
.tb.is-demo .tb__clockreal { color: transparent; }
.tb.is-demo .tb__ck {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.tb.is-demo .tb__ck--1 { animation-name: tbCk1; }
.tb.is-demo .tb__ck--2 { animation-name: tbCk2; }
.tb.is-demo .tb__bell {
animation-name: tbBell;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
// 말풍선 — 보임은 steps 컷, 자리 잡음은 transform 팝: 두 애니메이션을 나란히 돌린다
.tb.is-demo .tb__balloon {
visibility: visible; transition: none;
animation-name: tbBalO, tbBalX;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.tb.is-demo .tb__task {
transition: none;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.tb.is-demo #task1 { animation-name: tbT1; }
.tb.is-demo #task2 { animation-name: tbT2; }
@keyframes tbCk1 {
0%, 29% { opacity: 1; }
30%, 100% { opacity: 0; }
}
@keyframes tbCk2 {
0%, 29% { opacity: 0; }
30%, 100% { opacity: 1; }
}
@keyframes tbBell {
0%, 15% { color: $subject-ink; }
16%, 79% { color: $subject-orange-deep; }
80%, 100% { color: $subject-ink; }
}
@keyframes tbBalO {
0%, 15% { opacity: 0; }
16%, 75% { opacity: 1; }
76%, 100% { opacity: 0; }
}
@keyframes tbBalX {
0%, 16% { transform: translateY(5px) scale(.94); }
24%, 75% { transform: none; }
76%, 100% { transform: translateY(5px) scale(.94); }
}
@keyframes tbT1 {
0%, 39% { border-color: $edge $lite $lite $edge; background: rgba(23, 20, 26, .07); font-weight: 700; transform: translateY(1px); }
40%, 86% { border-color: $lite $edge $edge $lite; background: $subject-cream; font-weight: 500; transform: translateY(0); }
87%, 100% { border-color: $edge $lite $lite $edge; background: rgba(23, 20, 26, .07); font-weight: 700; transform: translateY(1px); }
}
@keyframes tbT2 {
0%, 39% { border-color: $lite $edge $edge $lite; background: $subject-cream; font-weight: 500; transform: translateY(0); }
40%, 86% { border-color: $edge $lite $lite $edge; background: rgba(23, 20, 26, .07); font-weight: 700; transform: translateY(1px); }
87%, 100% { border-color: $lite $edge $edge $lite; background: $subject-cream; font-weight: 500; transform: translateY(0); }
}
// ── 좁은 화면: 창과 시계 칸을 줄여 무대 안에 들어온다
@media (max-width: 340px) {
.tb { height: 168px; }
.tb__win { width: min(100%, 216px); }
.tb__task { padding: 0 $sp-1; }
.tb__clock { min-width: 54px; }
}
@media (prefers-reduced-motion: reduce) {
.tb.is-demo .tb__ck, .tb.is-demo .tb__bell, .tb.is-demo .tb__balloon, .tb.is-demo .tb__task {
animation: none !important;
}
.tb.is-demo .tb__clockreal { color: $subject-ink !important; }
.tb.is-demo .tb__ck { opacity: 0 !important; }
.tb.is-demo .tb__balloon { opacity: 0 !important; visibility: hidden !important; }
.tb.is-demo .tb__task { transition: 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);
}
.tb {
position: relative;
display: flex;
flex-direction: column;
box-sizing: border-box;
width: min(100%, 392px);
height: 204px;
max-height: calc(100vh - 32px);
border: 2px solid;
border-color: rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38) rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95);
background: #17141a;
}
.tb button {
font-family: inherit;
cursor: pointer;
}
.tb button:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 1px;
}
.tb__desk {
flex: 1 1 auto;
display: grid;
place-items: center;
padding: 8px;
min-height: 0;
}
.tb__win {
width: min(100%, 236px);
}
.tb__title {
display: flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 4px;
box-sizing: border-box;
background: #0a4fe8;
color: #fff7e6;
}
.tb__tico {
display: grid;
place-items: center;
}
.tb__tname {
flex: 1 1 auto;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
}
.tb__tbtns {
display: flex;
align-items: center;
gap: 4px;
}
.tb__gb {
width: 14px;
height: 13px;
box-sizing: border-box;
border: 2px solid rgba(255, 247, 230, 0.85);
}
.tb__gb--min {
border: 0;
border-bottom: 2px solid rgba(255, 247, 230, 0.85);
width: 12px;
align-self: flex-end;
}
.tb__body {
padding: 8px;
border: 2px solid;
border-color: rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38) rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95);
background: #fff7e6;
}
.tb__line {
margin: 0;
font-weight: 500;
font-size: 11px;
line-height: 1.45;
color: #17141a;
}
.tb__line + .tb__line {
margin-top: 4px;
}
.tb__line--mute {
color: rgba(23, 20, 26, 0.72);
}
.tb__bar {
display: flex;
align-items: center;
gap: 4px;
height: 34px;
padding: 0 4px;
box-sizing: border-box;
background: #fff7e6;
border-top: 1px solid rgba(23, 20, 26, 0.3);
}
.tb__start {
display: grid;
place-items: center;
width: 26px;
height: 26px;
color: #17141a;
}
.tb__task {
height: 26px;
padding: 0 8px;
box-sizing: border-box;
background: #fff7e6;
color: #17141a;
border: 2px solid;
border-color: rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38) rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95);
font-weight: 500;
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
transition: transform 220ms ease-out, background-color 220ms ease-out;
}
.tb__task[aria-pressed=true] {
border-color: rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95) rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38);
background: rgba(23, 20, 26, 0.07);
font-weight: 700;
transform: translateY(1px);
}
.tb__tray {
position: relative;
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
height: 26px;
padding: 0 4px;
box-sizing: border-box;
border: 2px solid;
border-color: rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95) rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38);
background: rgba(23, 20, 26, 0.05);
}
.tb__spk {
display: grid;
place-items: center;
color: #17141a;
}
.tb__bell {
display: grid;
place-items: center;
padding: 0;
border: 0;
background: none;
color: #17141a;
transition: color 220ms ease-out;
}
.tb__bell[aria-pressed=true] {
color: #c93000;
}
.tb__clock {
display: grid;
justify-items: center;
min-width: 62px;
}
.tb__clock > * {
grid-area: 1/1;
}
.tb__clockreal, .tb__ck {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
color: #17141a;
white-space: nowrap;
}
.tb__clockpre {
display: grid;
justify-items: center;
}
.tb__ck {
opacity: 0;
}
.tb__balloon {
position: absolute;
inset: auto 0 calc(100% + 4px) auto;
box-sizing: border-box;
padding: 4px 8px;
background: #ffffff;
border: 1px solid #17141a;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-weight: 600;
font-size: 11px;
line-height: 1.4;
color: #17141a;
white-space: nowrap;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(5px) scale(0.94);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), visibility 220ms;
}
.tb__bell[aria-pressed=true] ~ .tb__balloon {
opacity: 1;
visibility: visible;
transform: none;
}
.tb__balloon::after {
content: "";
position: absolute;
right: 20px;
bottom: -4px;
width: 8px;
height: 8px;
background: #ffffff;
border-right: 1px solid #17141a;
border-bottom: 1px solid #17141a;
transform: rotate(45deg);
}
.tb.is-demo .tb__clockreal {
color: transparent;
}
.tb.is-demo .tb__ck {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tb.is-demo .tb__ck--1 {
animation-name: tbCk1;
}
.tb.is-demo .tb__ck--2 {
animation-name: tbCk2;
}
.tb.is-demo .tb__bell {
animation-name: tbBell;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tb.is-demo .tb__balloon {
visibility: visible;
transition: none;
animation-name: tbBalO, tbBalX;
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;
}
.tb.is-demo .tb__task {
transition: none;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tb.is-demo #task1 {
animation-name: tbT1;
}
.tb.is-demo #task2 {
animation-name: tbT2;
}
@keyframes tbCk1 {
0%, 29% {
opacity: 1;
}
30%, 100% {
opacity: 0;
}
}
@keyframes tbCk2 {
0%, 29% {
opacity: 0;
}
30%, 100% {
opacity: 1;
}
}
@keyframes tbBell {
0%, 15% {
color: #17141a;
}
16%, 79% {
color: #c93000;
}
80%, 100% {
color: #17141a;
}
}
@keyframes tbBalO {
0%, 15% {
opacity: 0;
}
16%, 75% {
opacity: 1;
}
76%, 100% {
opacity: 0;
}
}
@keyframes tbBalX {
0%, 16% {
transform: translateY(5px) scale(0.94);
}
24%, 75% {
transform: none;
}
76%, 100% {
transform: translateY(5px) scale(0.94);
}
}
@keyframes tbT1 {
0%, 39% {
border-color: rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95) rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38);
background: rgba(23, 20, 26, 0.07);
font-weight: 700;
transform: translateY(1px);
}
40%, 86% {
border-color: rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38) rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95);
background: #fff7e6;
font-weight: 500;
transform: translateY(0);
}
87%, 100% {
border-color: rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95) rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38);
background: rgba(23, 20, 26, 0.07);
font-weight: 700;
transform: translateY(1px);
}
}
@keyframes tbT2 {
0%, 39% {
border-color: rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38) rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95);
background: #fff7e6;
font-weight: 500;
transform: translateY(0);
}
40%, 86% {
border-color: rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95) rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38);
background: rgba(23, 20, 26, 0.07);
font-weight: 700;
transform: translateY(1px);
}
87%, 100% {
border-color: rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38) rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95);
background: #fff7e6;
font-weight: 500;
transform: translateY(0);
}
}
@media (max-width: 340px) {
.tb {
height: 168px;
}
.tb__win {
width: min(100%, 216px);
}
.tb__task {
padding: 0 4px;
}
.tb__clock {
min-width: 54px;
}
}
@media (prefers-reduced-motion: reduce) {
.tb.is-demo .tb__ck, .tb.is-demo .tb__bell, .tb.is-demo .tb__balloon, .tb.is-demo .tb__task {
animation: none !important;
}
.tb.is-demo .tb__clockreal {
color: #17141a !important;
}
.tb.is-demo .tb__ck {
opacity: 0 !important;
}
.tb.is-demo .tb__balloon {
opacity: 0 !important;
visibility: hidden !important;
}
.tb.is-demo .tb__task {
transition: none !important;
}
}var root = document.getElementById('tb');
var clockReal = document.getElementById('clockreal');
// 시계 — 페이지 언어를 따라가는 toLocaleTimeString 으로 실제 시각을 분 단위로 찍는다
function tick() {
var lang = document.documentElement.lang || 'ko';
var text = new Date().toLocaleTimeString(lang, { hour: 'numeric', minute: '2-digit' });
if (clockReal.textContent !== text) clockReal.textContent = text;
}
tick();
setInterval(tick, 5000);
// 사람 손이 닿으면 격자 미리보기 루프를 끊고 실제 조작 경로로 돌아간다
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
// 단추 상태 토글 — 눌림/올라옴은 aria-pressed 로 CSS 가 읽는다
function flip(btn) {
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', on ? 'false' : 'true');
}
document.getElementById('bell').addEventListener('click', function () { flip(this); });
document.getElementById('task1').addEventListener('click', function () { flip(this); });
document.getElementById('task2').addEventListener('click', function () { flip(this); });// TaskbarTray — vanilla 판(run/023/demo/07-os-taskbar-clock-tray/index.html) 의 React 포트.
// 오른쪽 끝 시계가 toLocaleTimeString 으로 실제 시각을 분 단위로 세고, 종 모양 알림 칸을
// 누르면 그 위로 말풍선이 오른다. 창 단추는 눌림과 올라옴에서 border-color 의 밝은 면과
// 어두운 면 방향이 서로 반대로 뒤집힌다(상태는 aria-pressed 로 CSS 가 읽는다).
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TaskbarTray.module.scss";
export type TaskbarTrayProps = {
duration?: number;
easing?: string;
color?: string;
winName?: string;
taskA?: string;
taskB?: string;
balloonText?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const NOTE_A = "하단 바의 시계는 지금 시각을 분 단위로 셉니다";
const NOTE_B = "종 모양 알림을 누르면 말풍선이 뜹니다";
const CLOCK_FALLBACK = "오후 9:41";
const CLOCK_PREVIEW_1 = "오후 9:41";
const CLOCK_PREVIEW_2 = "오후 9:42";
const ARIA_ALERT = "알림";
function nowText(): string {
const lang = document.documentElement.lang || "ko";
return new Date().toLocaleTimeString(lang, { hour: "numeric", minute: "2-digit" });
}
export default function TaskbarTray(props: TaskbarTrayProps) {
const duration = props.duration ?? 220;
const easing = props.easing ?? "cubic-bezier(.2, 1.4, .4, 1)";
const color = props.color ?? "#0a4fe8";
const winName = props.winName ?? "메모장";
const taskA = props.taskA ?? "메모장";
const taskB = props.taskB ?? "그림판";
const balloonText = props.balloonText ?? "업데이트가 준비되었습니다";
const [preview, setPreview] = useState<boolean>(true);
const [time, setTime] = useState<string>(CLOCK_FALLBACK);
const [alertOn, setAlertOn] = useState<boolean>(false);
const [aOn, setAOn] = useState<boolean>(true);
const [bOn, setBOn] = useState<boolean>(false);
useEffect(() => {
setTime(nowText());
const timer = window.setInterval(() => setTime(nowText()), 5000);
return () => window.clearInterval(timer);
}, []);
const leave = () => setPreview(false);
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={leave}
>
<div className={styles.desk}>
<div className={styles.win}>
<div className={styles.title}>
<span className={styles.tico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M128 144h80v72l-80-14.55Zm-96 40l64 11.64V144H32Zm96-129.45V112h80V40ZM32 112h64V60.36L32 72Z" opacity=".2" />
<path d="M208 136h-80a8 8 0 0 0-8 8v57.45a8 8 0 0 0 6.57 7.88l80 14.54a7.6 7.6 0 0 0 1.43.13a8 8 0 0 0 8-8v-72a8 8 0 0 0-8-8m-8 70.41l-64-11.63V152h64ZM96 136H32a8 8 0 0 0-8 8v40a8 8 0 0 0 6.57 7.87l64 11.64a8.5 8.5 0 0 0 1.43.13a8 8 0 0 0 8-8V144a8 8 0 0 0-8-8m-8 50.05l-48-8.73V152h48ZM213.13 33.86a8 8 0 0 0-6.56-1.73l-80 14.55a8 8 0 0 0-6.57 7.87V112a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8V40a8 8 0 0 0-2.87-6.14M200 104h-64V61.22l64-11.63Zm-98.87-49.78a8 8 0 0 0-6.56-1.73l-64 11.64A8 8 0 0 0 24 72v40a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8V60.36a8 8 0 0 0-2.87-6.14M88 104H40V78.68L88 70Z" />
</g>
</svg>
</span>
<span className={styles.tname}>{winName}</span>
<span className={styles.tbtns} aria-hidden="true">
<span className={styles.gb + " " + styles.gbMin} />
<span className={styles.gb} />
</span>
</div>
<div className={styles.wbody}>
<p className={styles.line}>{NOTE_A}</p>
<p className={styles.line + " " + styles.lineMute}>{NOTE_B}</p>
</div>
</div>
</div>
<div className={styles.bar}>
<span className={styles.start} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M128 144h80v72l-80-14.55Zm-96 40l64 11.64V144H32Zm96-129.45V112h80V40ZM32 112h64V60.36L32 72Z" opacity=".2" />
<path d="M208 136h-80a8 8 0 0 0-8 8v57.45a8 8 0 0 0 6.57 7.88l80 14.54a7.6 7.6 0 0 0 1.43.13a8 8 0 0 0 8-8v-72a8 8 0 0 0-8-8m-8 70.41l-64-11.63V152h64ZM96 136H32a8 8 0 0 0-8 8v40a8 8 0 0 0 6.57 7.87l64 11.64a8.5 8.5 0 0 0 1.43.13a8 8 0 0 0 8-8V144a8 8 0 0 0-8-8m-8 50.05l-48-8.73V152h48ZM213.13 33.86a8 8 0 0 0-6.56-1.73l-80 14.55a8 8 0 0 0-6.57 7.87V112a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8V40a8 8 0 0 0-2.87-6.14M200 104h-64V61.22l64-11.63Zm-98.87-49.78a8 8 0 0 0-6.56-1.73l-64 11.64A8 8 0 0 0 24 72v40a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8V60.36a8 8 0 0 0-2.87-6.14M88 104H40V78.68L88 70Z" />
</g>
</svg>
</span>
<button type="button" className={styles.task} aria-pressed={aOn} onClick={() => setAOn((v) => !v)}>
{taskA}
</button>
<button type="button" className={styles.task} aria-pressed={bOn} onClick={() => setBOn((v) => !v)}>
{taskB}
</button>
<div className={styles.tray}>
<span className={styles.spk} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2" />
<path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58M248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128" />
</g>
</svg>
</span>
<button
type="button"
className={styles.bell}
aria-pressed={alertOn}
aria-label={ARIA_ALERT}
onClick={() => setAlertOn((v) => !v)}
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" />
<path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" />
</g>
</svg>
</button>
<span className={styles.clock}>
<span className={styles.clockReal}>{time}</span>
<span className={styles.clockPre} aria-hidden="true">
<span className={styles.ck + " " + styles.ck1}>{CLOCK_PREVIEW_1}</span>
<span className={styles.ck + " " + styles.ck2}>{CLOCK_PREVIEW_2}</span>
</span>
</span>
<span className={styles.balloon}>{balloonText}</span>
</div>
</div>
</div>
</main>
);
}07 · 작업표시줄 시계와 알림 칸 — 측정 누적면적 5.797% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 시계 — 페이지 언어를 따라가는 toLocaleTimeString 으로 실제 시각을 분 단위로 찍는다
function tick() {
var lang = document.documentElement.lang || 'ko';
var text = new Date().toLocaleTimeString(lang, { hour: 'numeric', minute: '2-digit' });
if (clockReal.textContent !== text) clockReal.textContent = text;
}
tick();
setInterval(tick, 5000);
형제 선택자 하나로 뜨는 말풍선
알림 종이 켜지면 말풍선이 트레이 위로 떠오르는 것까지 CSS 형제 선택자 하나로 되어 있어 상태를 늘 보여 줘야 하는 하단 바에 그대로 옮겨 붙이고, 바늘 각도까지 세는 시계는 자바스크립트 아날로그 시계 9종이 맡습니다.
08설치 진행 막대와 단계 문구
매끄럽게 늘지 않는 열두 칸
진행 막대는 칸 도안 — linear-gradient 하나를 80% 칸 + 20% 줄 비율로 정의해 background-size 20px 타일로 박은 뒤 — 트랙색 덮개를 steps(12, end) 로 줄여 네모 칸이 왼쪽부터 하나씩 드러납니다. 칸 박자마다 아래 문구 다섯 장과 남은 용량 숫자 열두 장이 같은 박자로 컷 갈아 끼워지고, 다 차면 완료 표시가 한 번 튀어오릅니다.

<main class="stage">
<div class="ip is-demo" id="ip">
<div class="ip__title">
<span class="ip__tico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 152v48a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8v-48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8m-8-104H48a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8" opacity=".2"/><path d="M208 136H48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64H48v-48h160zm0-160H48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64H48V56h160zm-16-24a12 12 0 1 1-12-12a12 12 0 0 1 12 12m0 96a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></span>
<span class="ip__tname">설치 마법사</span>
<span class="ip__tbtns" aria-hidden="true"><span class="ip__gb ip__gb--min"></span><span class="ip__gb"></span></span>
</div>
<div class="ip__body">
<div class="ip__track">
<span class="ip__fill" aria-hidden="true"></span>
<span class="ip__cover" aria-hidden="true"></span>
<span class="ip__done" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
</div>
<div class="ip__meta">
<span class="ip__phrases" aria-hidden="true">
<span class="ip__sv ip__sv--1">파일 준비</span>
<span class="ip__sv ip__sv--2">복사 중</span>
<span class="ip__sv ip__sv--3">설정 저장</span>
<span class="ip__sv ip__sv--4">마무리 중</span>
<span class="ip__sv ip__sv--5">설치 완료</span>
</span>
<span class="ip__nums" aria-hidden="true">
<span class="ip__nm ip__nm--1">812 MB</span>
<span class="ip__nm ip__nm--2">744 MB</span>
<span class="ip__nm ip__nm--3">663 MB</span>
<span class="ip__nm ip__nm--4">581 MB</span>
<span class="ip__nm ip__nm--5">502 MB</span>
<span class="ip__nm ip__nm--6">421 MB</span>
<span class="ip__nm ip__nm--7">340 MB</span>
<span class="ip__nm ip__nm--8">262 MB</span>
<span class="ip__nm ip__nm--9">183 MB</span>
<span class="ip__nm ip__nm--10">102 MB</span>
<span class="ip__nm ip__nm--11">47 MB</span>
<span class="ip__nm ip__nm--12">0 MB</span>
</span>
</div>
<div class="ip__actions">
<button type="button" class="ip__btn" id="retry">다시 설치</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 설치 진행 막대와 단계 문구 — 막대가 매끄럽게 늘지 않는다. linear-gradient 하나를
// 칸(타일) 도안으로 정의하고 background-size 로 타일 폭을 박은 뒤(정지점은 타일 비율:
// 80% 블록 + 20% 줄), 그 위에 트랙색 덮개를 transform: scaleX 로 steps(12, end) 만큼
// 줄여 칸을 하나씩 드러낸다. 칸이 넘어갈 때마다 문구와 남은 용량 숫자가 같은 박자로 컷으로
// 갈아 끼워지고(음수 animation-delay 로 위상을 밀어 한 줄의 키프레임을 열두 장이 나눠 쓴다),
// 다 차면 완료 표시가 한 번 튀어오른다.
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue-deep;
$tile: 20px; // 칸 한 칸 = 블록 16px + 줄 4px
$cells: 12; // 트랙 안 칸 수 — steps(12, end) 의 박자와 1:1 이다
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$edge: rgba(23, 20, 26, .38);
$lite: rgba(255, 255, 255, .95);
$muted: rgba(23, 20, 26, .72);
@include stage($stage-paper);
// ── 설치 대화상자
.ip {
box-sizing: border-box; width: min(100%, 276px);
border: 2px solid; border-color: $lite $edge $edge $lite;
background: $subject-cream; box-shadow: $shadow-raised;
}
.ip button { font-family: inherit; cursor: pointer; }
.ip button:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
// ── 제목줄
.ip__title {
display: flex; align-items: center; gap: $sp-1;
height: 24px; padding: 0 $sp-1; box-sizing: border-box;
background: $color; color: $subject-cream;
}
.ip__tico { display: grid; place-items: center; }
.ip__tname { flex: 1 1 auto; font-weight: 700; font-size: 12px; line-height: 1.4; white-space: nowrap; overflow: hidden; }
.ip__tbtns { display: flex; align-items: center; gap: $sp-1; }
.ip__gb { width: 14px; height: 13px; box-sizing: border-box; border: 2px solid rgba(255, 247, 230, .85); }
.ip__gb--min { border: 0; border-bottom: 2px solid rgba(255, 247, 230, .85); width: 12px; align-self: flex-end; }
// ── 본문
.ip__body { display: flex; flex-direction: column; gap: $sp-2; padding: $sp-3; }
// 진행 트랙 — 눌려 들어간 칸(어두운 면이 위·왼쪽). 안은 정확히 칸 12개 폭이다
.ip__track {
position: relative; align-self: center; box-sizing: content-box;
width: $tile * $cells; height: 20px;
border: 2px solid; border-color: $edge $lite $lite $edge;
background: $stage-paper;
}
// 칸 도안 — 타일 하나(20px)를 background-size 로 정의하고 정지점은 타일 비율로 준다.
// 정지점만 픽셀로 주면 타일이 아니라 요소 전체 폭의 그라디언트가 된다(실패표 269).
.ip__fill {
position: absolute; inset: 0;
background-image: linear-gradient(90deg, $color 0 80%, transparent 80% 100%);
background-size: $tile 100%;
background-repeat: repeat-x;
}
// 트랙색 덮개 — 오른쪽에 붙어 scaleX 로 줄어들며 칸을 왼쪽부터 드러낸다.
// 채움 시작 변에 붙어야 하므로 transform-origin 을 오른쪽 가운데로 둔다(중심 아님의 이유)
.ip__cover {
position: absolute; inset: 0;
background: $stage-paper;
transform-origin: right center;
transform: scaleX(0);
}
// 완료 표시 — 트랙 오른쪽 끝에 얹힌 체크. 정적 기본값은 보임(완료 상태)
.ip__done {
position: absolute; top: 2px; right: 2px; width: 16px; height: 16px;
display: grid; place-items: center; color: $stage-paper; z-index: 1;
}
// ── 문구·숫자 줄 — 겹켜진 값 장을 컷으로 갈아 끼운다(정적 기본값은 마지막 장)
.ip__meta {
display: flex; align-items: baseline; justify-content: space-between;
align-self: center; width: $tile * $cells;
}
.ip__phrases, .ip__nums { display: grid; }
.ip__phrases > *, .ip__nums > * { grid-area: 1 / 1; }
.ip__phrases { justify-items: start; }
.ip__nums { justify-items: end; }
.ip__sv { font-weight: 700; font-size: 11px; line-height: 1.4; color: $subject-ink; white-space: nowrap; }
.ip__nm { font-family: $mono; font-weight: 700; font-size: 11px; line-height: 1.4; color: $muted; white-space: nowrap; }
.ip__sv, .ip__nm { opacity: 0; }
.ip__sv--5, .ip__nm--12 { opacity: 1; }
// ── 단추
.ip__actions { display: flex; justify-content: flex-end; }
.ip__btn {
min-width: 88px; height: 26px; box-sizing: border-box; padding: 0 $sp-2;
background: $subject-cream; color: $subject-ink;
border: 2px solid; border-color: $lite $edge $edge $lite;
font-weight: 700; font-size: 11px; line-height: 1.4;
transition: transform $duration $ease-out;
}
.ip__btn:active { border-color: $edge $lite $lite $edge; transform: translateY(1px); }
// ── 격자 미리보기 루프 (2초): 덮개가 steps(12, end) 로 0 부터 75% 사이에 칸을 하나씩 드러내고
// (칸 박자 6.25% 간격), 문구는 네 박자, 숫자는 칸 박자에 맞춘다. 75% 부터 88% 사이에 완료 표시가
// 튀고, 90% 안에 모든 연출이 빠져 다음 바퀴를 준비한다.
.ip.is-demo .ip__cover {
animation-name: ipCover;
animation-duration: $dur-loop; animation-timing-function: steps(12, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.ip.is-demo .ip__sv, .ip.is-demo .ip__nm {
animation-name: ipSv; // 문구 장: 제자리에서 18.75% 동안 켜진다(네 박자)
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.ip.is-demo .ip__nm { animation-name: ipNm; } // 숫자 장: 6.25% 동안 켜진다(칸 박자)
// 음수 animation-delay — 같은 키프레임을 각자의 박자로 위상 이동해 쓴다(루프마다 같은 간격 유지)
.ip.is-demo .ip__sv--1 { animation-delay: 0ms; }
.ip.is-demo .ip__sv--2 { animation-delay: -1625ms; }
.ip.is-demo .ip__sv--3 { animation-delay: -1250ms; }
.ip.is-demo .ip__sv--4 { animation-delay: -875ms; }
.ip.is-demo .ip__sv--5 { animation-delay: -500ms; }
.ip.is-demo .ip__nm--1 { animation-delay: 0ms; }
.ip.is-demo .ip__nm--2 { animation-delay: -1875ms; }
.ip.is-demo .ip__nm--3 { animation-delay: -1750ms; }
.ip.is-demo .ip__nm--4 { animation-delay: -1625ms; }
.ip.is-demo .ip__nm--5 { animation-delay: -1500ms; }
.ip.is-demo .ip__nm--6 { animation-delay: -1375ms; }
.ip.is-demo .ip__nm--7 { animation-delay: -1250ms; }
.ip.is-demo .ip__nm--8 { animation-delay: -1125ms; }
.ip.is-demo .ip__nm--9 { animation-delay: -1000ms; }
.ip.is-demo .ip__nm--10 { animation-delay: -875ms; }
.ip.is-demo .ip__nm--11 { animation-delay: -750ms; }
.ip.is-demo .ip__nm--12 { animation-delay: -625ms; }
// 완료 표시 — 스스로 완결한 요소라 자기 중심에서 커진다
.ip.is-demo .ip__done {
animation-name: ipDone;
animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes ipCover {
0% { transform: scaleX(1); }
75%, 100% { transform: scaleX(0); }
}
@keyframes ipSv {
0%, 18.75% { opacity: 1; }
18.76%, 100% { opacity: 0; }
}
@keyframes ipNm {
0%, 6.25% { opacity: 1; }
6.26%, 100% { opacity: 0; }
}
@keyframes ipDone {
0%, 74% { opacity: 0; transform: scale(.3); }
75% { opacity: 1; transform: scale(.4); }
80% { transform: scale(1.3); }
84%, 86% { opacity: 1; transform: scale(1); }
87%, 100% { opacity: 0; transform: scale(1); }
}
// ── 좁은 화면: 본문 여백을 줄인다(트랙 폭 240px 는 고정 — 칸 도안의 기준이라 못 줄인다)
@media (max-width: 340px) {
.ip__body { gap: $sp-1; padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.ip.is-demo .ip__cover, .ip.is-demo .ip__sv, .ip.is-demo .ip__nm, .ip.is-demo .ip__done {
animation: none !important;
}
.ip.is-demo .ip__cover { transform: scaleX(0) !important; }
.ip.is-demo .ip__sv, .ip.is-demo .ip__nm { opacity: 0 !important; }
.ip.is-demo .ip__sv--5, .ip.is-demo .ip__nm--12 { opacity: 1 !important; }
.ip.is-demo .ip__done { opacity: 1 !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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ip {
box-sizing: border-box;
width: min(100%, 276px);
border: 2px solid;
border-color: rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38) rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95);
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ip button {
font-family: inherit;
cursor: pointer;
}
.ip button:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 1px;
}
.ip__title {
display: flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 4px;
box-sizing: border-box;
background: #0a4fe8;
color: #fff7e6;
}
.ip__tico {
display: grid;
place-items: center;
}
.ip__tname {
flex: 1 1 auto;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
}
.ip__tbtns {
display: flex;
align-items: center;
gap: 4px;
}
.ip__gb {
width: 14px;
height: 13px;
box-sizing: border-box;
border: 2px solid rgba(255, 247, 230, 0.85);
}
.ip__gb--min {
border: 0;
border-bottom: 2px solid rgba(255, 247, 230, 0.85);
width: 12px;
align-self: flex-end;
}
.ip__body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
.ip__track {
position: relative;
align-self: center;
box-sizing: content-box;
width: 240px;
height: 20px;
border: 2px solid;
border-color: rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95) rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38);
background: #ffffff;
}
.ip__fill {
position: absolute;
inset: 0;
background-image: linear-gradient(90deg, #0a4fe8 0 80%, transparent 80% 100%);
background-size: 20px 100%;
background-repeat: repeat-x;
}
.ip__cover {
position: absolute;
inset: 0;
background: #ffffff;
transform-origin: right center;
transform: scaleX(0);
}
.ip__done {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
display: grid;
place-items: center;
color: #ffffff;
z-index: 1;
}
.ip__meta {
display: flex;
align-items: baseline;
justify-content: space-between;
align-self: center;
width: 240px;
}
.ip__phrases, .ip__nums {
display: grid;
}
.ip__phrases > *, .ip__nums > * {
grid-area: 1/1;
}
.ip__phrases {
justify-items: start;
}
.ip__nums {
justify-items: end;
}
.ip__sv {
font-weight: 700;
font-size: 11px;
line-height: 1.4;
color: #17141a;
white-space: nowrap;
}
.ip__nm {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
white-space: nowrap;
}
.ip__sv, .ip__nm {
opacity: 0;
}
.ip__sv--5, .ip__nm--12 {
opacity: 1;
}
.ip__actions {
display: flex;
justify-content: flex-end;
}
.ip__btn {
min-width: 88px;
height: 26px;
box-sizing: border-box;
padding: 0 8px;
background: #fff7e6;
color: #17141a;
border: 2px solid;
border-color: rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38) rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95);
font-weight: 700;
font-size: 11px;
line-height: 1.4;
transition: transform 220ms ease-out;
}
.ip__btn:active {
border-color: rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95) rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38);
transform: translateY(1px);
}
.ip.is-demo .ip__cover {
animation-name: ipCover;
animation-duration: 2s;
animation-timing-function: steps(12, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ip.is-demo .ip__sv, .ip.is-demo .ip__nm {
animation-name: ipSv;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ip.is-demo .ip__nm {
animation-name: ipNm;
}
.ip.is-demo .ip__sv--1 {
animation-delay: 0ms;
}
.ip.is-demo .ip__sv--2 {
animation-delay: -1625ms;
}
.ip.is-demo .ip__sv--3 {
animation-delay: -1250ms;
}
.ip.is-demo .ip__sv--4 {
animation-delay: -875ms;
}
.ip.is-demo .ip__sv--5 {
animation-delay: -500ms;
}
.ip.is-demo .ip__nm--1 {
animation-delay: 0ms;
}
.ip.is-demo .ip__nm--2 {
animation-delay: -1875ms;
}
.ip.is-demo .ip__nm--3 {
animation-delay: -1750ms;
}
.ip.is-demo .ip__nm--4 {
animation-delay: -1625ms;
}
.ip.is-demo .ip__nm--5 {
animation-delay: -1500ms;
}
.ip.is-demo .ip__nm--6 {
animation-delay: -1375ms;
}
.ip.is-demo .ip__nm--7 {
animation-delay: -1250ms;
}
.ip.is-demo .ip__nm--8 {
animation-delay: -1125ms;
}
.ip.is-demo .ip__nm--9 {
animation-delay: -1000ms;
}
.ip.is-demo .ip__nm--10 {
animation-delay: -875ms;
}
.ip.is-demo .ip__nm--11 {
animation-delay: -750ms;
}
.ip.is-demo .ip__nm--12 {
animation-delay: -625ms;
}
.ip.is-demo .ip__done {
animation-name: ipDone;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes ipCover {
0% {
transform: scaleX(1);
}
75%, 100% {
transform: scaleX(0);
}
}
@keyframes ipSv {
0%, 18.75% {
opacity: 1;
}
18.76%, 100% {
opacity: 0;
}
}
@keyframes ipNm {
0%, 6.25% {
opacity: 1;
}
6.26%, 100% {
opacity: 0;
}
}
@keyframes ipDone {
0%, 74% {
opacity: 0;
transform: scale(0.3);
}
75% {
opacity: 1;
transform: scale(0.4);
}
80% {
transform: scale(1.3);
}
84%, 86% {
opacity: 1;
transform: scale(1);
}
87%, 100% {
opacity: 0;
transform: scale(1);
}
}
@media (max-width: 340px) {
.ip__body {
gap: 4px;
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ip.is-demo .ip__cover, .ip.is-demo .ip__sv, .ip.is-demo .ip__nm, .ip.is-demo .ip__done {
animation: none !important;
}
.ip.is-demo .ip__cover {
transform: scaleX(0) !important;
}
.ip.is-demo .ip__sv, .ip.is-demo .ip__nm {
opacity: 0 !important;
}
.ip.is-demo .ip__sv--5, .ip.is-demo .ip__nm--12 {
opacity: 1 !important;
}
.ip.is-demo .ip__done {
opacity: 1 !important;
transform: none !important;
}
}var root = document.getElementById('ip');
var retry = document.getElementById('retry');
// 사람 손이 닿으면 격자 미리보기 루프를 끊고 완료 상태(정적 기본값)로 돌아간다
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
// 다시 설치 — 같은 2초 루프를 한 바퀴 돌게 했다가 95% 지점(막대가 다 찬 뒤)에 뗀다
retry.addEventListener('click', function () {
root.classList.add('is-demo');
clearTimeout(retry.timer);
retry.timer = setTimeout(function () { root.classList.remove('is-demo'); }, 1900);
});// InstallProgress — vanilla 판(run/023/demo/08-os-install-progress-steps/index.html) 의 React 포트.
// 칸 도안은 linear-gradient 하나가 background-size 타일(20px)로 정의하고, 트랙색 덮개가
// transform: scaleX 를 steps(12, end) 만큼 줄여 칸을 하나씩 드러낸다. 문구와 남은 용량 숫자는
// 겹켜진 값 장을 음수 animation-delay 위상으로 컷 갈아 끼운다. 다시 설치 단추는 같은 루프를
// 한 바퀴 돌게 했다가 95% 지점에 뗀다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./InstallProgress.module.scss";
export type InstallProgressProps = {
duration?: number;
easing?: string;
color?: string;
titleText?: string;
retryText?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef<number> 는 { current: number | null } 을 돌려준다 — ambient 타입에 맞춘 상자 타입
type Box<T> = { current: T | null };
const TITLE = "설치 마법사";
const RETRY = "다시 설치";
const PHRASES = ["파일 준비", "복사 중", "설정 저장", "마무리 중", "설치 완료"];
const REMAINS = ["812 MB", "744 MB", "663 MB", "581 MB", "502 MB", "421 MB", "340 MB", "262 MB", "183 MB", "102 MB", "47 MB", "0 MB"];
const REPLAY_MS = 1900;
export default function InstallProgress(props: InstallProgressProps) {
const duration = props.duration ?? 220;
const easing = props.easing ?? "cubic-bezier(.2, 1.4, .4, 1)";
const color = props.color ?? "#0a4fe8";
const titleText = props.titleText ?? TITLE;
const retryText = props.retryText ?? RETRY;
const [preview, setPreview] = useState<boolean>(true);
const timer = useRef<number>(null) as Box<number>;
useEffect(() => {
return () => {
if (timer.current !== null) window.clearTimeout(timer.current);
};
}, []);
const leave = () => setPreview(false);
const onRetry = () => {
// 같은 2초 루프를 한 바퀴 돌게 했다가 95% 지점(막대가 다 찬 뒤)에 뗀다
setPreview(true);
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setPreview(false), REPLAY_MS);
};
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={leave}
>
<div className={styles.title}>
<span className={styles.tico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M216 152v48a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8v-48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8m-8-104H48a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8" opacity=".2" />
<path d="M208 136H48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64H48v-48h160zm0-160H48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64H48V56h160zm-16-24a12 12 0 1 1-12-12a12 12 0 0 1 12 12m0 96a12 12 0 1 1-12-12a12 12 0 0 1 12 12" />
</g>
</svg>
</span>
<span className={styles.tname}>{titleText}</span>
<span className={styles.tbtns} aria-hidden="true">
<span className={styles.gb + " " + styles.gbMin} />
<span className={styles.gb} />
</span>
</div>
<div className={styles.body}>
<div className={styles.track}>
<span className={styles.fill} aria-hidden="true" />
<span className={styles.cover} aria-hidden="true" />
<span className={styles.done} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
</span>
</div>
<div className={styles.meta}>
<span className={styles.phrases} aria-hidden="true">
{PHRASES.map((text, i) => (
<span key={i} className={styles.sv + " " + styles["sv" + (i + 1)]}>
{text}
</span>
))}
</span>
<span className={styles.nums} aria-hidden="true">
{REMAINS.map((text, i) => (
<span key={i} className={styles.nm + " " + styles["nm" + (i + 1)]}>
{text}
</span>
))}
</span>
</div>
<div className={styles.actions}>
<button type="button" className={styles.btn} onClick={onRetry}>
{retryText}
</button>
</div>
</div>
</div>
</main>
);
}08 · 설치 진행 막대와 단계 문구 — 측정 누적면적 2.866% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.ip__fill {
position: absolute; inset: 0;
background-image: linear-gradient(90deg, $color 0 80%, transparent 80% 100%);
background-size: $tile 100%;
background-repeat: repeat-x;
}
// 트랙색 덮개 — 오른쪽에 붙어 scaleX 로 줄어들며 칸을 왼쪽부터 드러낸다.
// 채움 시작 변에 붙어야 하므로 transform-origin 을 오른쪽 가운데로 둔다(중심 아님의 이유)
.ip__cover {
position: absolute; inset: 0;
background: $stage-paper;
transform-origin: right center;
transform: scaleX(0);
}
한 키프레임을 열일곱 장이 나눠 쓰는 음수 지연
문구와 숫자 장들은 같은 키프레임을 음수 animation-delay 로 위상만 달리해 쓰는데, 이 짜임이 오래 걸리는 작업의 대기 화면에 얹어도 박자가 어긋나지 않게 합니다.
09파란 오류 화면과 재시작
한 프레임에 통째로 뜨는 파란 오류
오류 문구는 전환 없이 steps(1, end) 컷으로 한 프레임에 통째로 나타나고, 안내 줄의 점 세 개가 차례로 켜진 채 남습니다. 재시작을 누르면 clip-path 위프로 화면이 위에서 아래로 쓸려 나가 원래 창이 드러나고, transitionend 뒤에 상태가 정리됩니다.

<main class="stage">
<div class="bs is-demo" id="bs">
<div class="bs__win">
<div class="bs__title">
<span class="bs__tico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M128 144h80v72l-80-14.55Zm-96 40l64 11.64V144H32Zm96-129.45V112h80V40ZM32 112h64V60.36L32 72Z" opacity=".2"/><path d="M208 136h-80a8 8 0 0 0-8 8v57.45a8 8 0 0 0 6.57 7.88l80 14.54a7.6 7.6 0 0 0 1.43.13a8 8 0 0 0 8-8v-72a8 8 0 0 0-8-8m-8 70.41l-64-11.63V152h64ZM96 136H32a8 8 0 0 0-8 8v40a8 8 0 0 0 6.57 7.87l64 11.64a8.5 8.5 0 0 0 1.43.13a8 8 0 0 0 8-8V144a8 8 0 0 0-8-8m-8 50.05l-48-8.73V152h48ZM213.13 33.86a8 8 0 0 0-6.56-1.73l-80 14.55a8 8 0 0 0-6.57 7.87V112a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8V40a8 8 0 0 0-2.87-6.14M200 104h-64V61.22l64-11.63Zm-98.87-49.78a8 8 0 0 0-6.56-1.73l-64 11.64A8 8 0 0 0 24 72v40a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8V60.36a8 8 0 0 0-2.87-6.14M88 104H40V78.68L88 70Z"/></g></svg></span>
<span class="bs__tname">시스템 정보</span>
<span class="bs__tbtns" aria-hidden="true"><span class="bs__gb bs__gb--min"></span><span class="bs__gb"></span></span>
</div>
<div class="bs__body">
<p class="bs__row"><span class="bs__k">프로세서</span><span class="bs__v">486DX2 66MHz</span></p>
<p class="bs__row"><span class="bs__k">메모리</span><span class="bs__v">16 MB</span></p>
<p class="bs__row"><span class="bs__k">디스크</span><span class="bs__v">540 MB</span></p>
<div class="bs__actions">
<button type="button" class="bs__btn" id="crash">시스템 오류</button>
</div>
</div>
</div>
<div class="bs__screen" id="screen">
<div class="bs__err">
<p class="bs__eline">치명적 오류 0E : 016F : BFF9B3D4</p>
<p class="bs__eline">VXD VMM(01) 을 처리하다 문제가 생겼습니다</p>
<p class="bs__eline">현재 작업을 저장하고 시스템을 다시 시작합니다</p>
</div>
<div class="bs__guide">
<span class="bs__gtext">상태를 저장하는 중</span>
<span class="bs__dots" aria-hidden="true"><span class="bs__dot"></span><span class="bs__dot"></span><span class="bs__dot"></span></span>
</div>
<div class="bs__sact">
<button type="button" class="bs__btn bs__btn--s" id="restart">재시작</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 파란 오류 화면과 재시작 — 오류 문구는 steps(1, end) 컷으로 한 프레임에 통째로 나타난다
// (0%{A} 100%{B} 한 구간이면 end 타이밍이 구간 끝에서만 바뀌어 2초 내내 A 만 보이므로
// 컷 지점을 7%·8% 중간 키프레임으로 넣는다). 안내 줄의 점 세 개는 음수 animation-delay 로
// 위상을 밀어 20%·30%·40% 에 차례로 켜지고 켜진 채로 남는다. 재시작을 누르면 화면이
// clip-path 위프로 위에서 아래로 쓸려 사라지는데, 위프는 steps 와 섞지 않고 이징 짝
// 애니메이션으로 분리했다(실패표 266). 루프에서는 쓸려 나간 자리에 원래 창이 그대로
// 드러나 화면이 비지 않는다(실패표 255).
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue-deep; // 크림 글자를 얹는 파란 면은 한 단계 진한 쪽(5.98:1)
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$edge: rgba(23, 20, 26, .38);
$lite: rgba(255, 255, 255, .95);
$muted: rgba(23, 20, 26, .72);
@include stage($stage-ink);
// ── 원래 창
.bs {
position: relative;
box-sizing: border-box; width: min(100%, 336px); height: 196px;
max-height: calc(100vh - 32px);
border: 2px solid; border-color: $lite $edge $edge $lite;
background: $subject-cream; box-shadow: $shadow-raised;
}
.bs button { font-family: inherit; cursor: pointer; }
.bs button:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 1px; }
.bs__win { display: flex; flex-direction: column; height: 100%; }
.bs__title {
display: flex; align-items: center; gap: $sp-1;
height: 24px; padding: 0 $sp-1; box-sizing: border-box;
background: $color; color: $subject-cream;
}
.bs__tico { display: grid; place-items: center; }
.bs__tname { flex: 1 1 auto; font-weight: 700; font-size: 12px; line-height: 1.4; white-space: nowrap; overflow: hidden; }
.bs__tbtns { display: flex; align-items: center; gap: $sp-1; }
.bs__gb { width: 14px; height: 13px; box-sizing: border-box; border: 2px solid rgba(255, 247, 230, .85); }
.bs__gb--min { border: 0; border-bottom: 2px solid rgba(255, 247, 230, .85); width: 12px; align-self: flex-end; }
.bs__body { display: flex; flex-direction: column; gap: $sp-1; padding: $sp-3; }
.bs__row { margin: 0; display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.bs__k { font-weight: 500; font-size: 12px; line-height: 1.5; color: $muted; }
.bs__v { font-family: $mono; font-weight: 700; font-size: 12px; line-height: 1.5; color: $subject-ink; }
.bs__actions { display: flex; justify-content: flex-end; margin-top: $sp-2; }
// ── 단추 — 올라온 상태는 밝은 면이 위·왼쪽, 눌림은 반대
.bs__btn {
min-width: 96px; height: 26px; box-sizing: border-box; padding: 0 $sp-2;
background: $subject-cream; color: $subject-ink;
border: 2px solid; border-color: $lite $edge $edge $lite;
font-weight: 700; font-size: 11px; line-height: 1.4;
transition: transform $dur-quick $ease-out;
}
.bs__btn:active { border-color: $edge $lite $lite $edge; transform: translateY(1px); }
// ── 파란 오류 화면 — 창을 통째로 덮는다. 숨김은 visibility 로(탭 순서에서도 빠진다)
.bs__screen {
position: absolute; inset: 0; z-index: 2; box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-3; padding: $sp-3;
background: $color; clip-path: inset(0 0 0 0);
opacity: 0; visibility: hidden; pointer-events: none;
}
.bs__screen.is-crash { opacity: 1; visibility: visible; pointer-events: auto; transition: clip-path $duration $easing; }
// 재시작 — 위쪽 inset 이 100% 로 자라며 파란 면이 위에서 아래로 쓸려 나간다
.bs__screen.is-crash.is-wiped { clip-path: inset(100% 0 0 0); }
.bs__err { margin-top: $sp-3; display: flex; flex-direction: column; gap: $sp-1; }
.bs__eline { margin: 0; font-family: $mono; font-weight: 600; font-size: 12px; line-height: 1.5; color: $subject-cream; }
.bs__guide { display: flex; align-items: center; gap: $sp-2; margin-top: auto; }
.bs__gtext { font-family: $mono; font-weight: 600; font-size: 12px; line-height: 1.5; color: $subject-cream; }
.bs__dots { display: flex; align-items: center; gap: $sp-1; }
.bs__dot { width: 8px; height: 8px; background: rgba(255, 247, 230, .35); }
.bs__sact { display: flex; justify-content: center; }
// ── 격자 미리보기 루프 (2초): 8% 에 파란 화면이 컷으로 들어오고, 점이 20%·30%·40% 에
// 차례로 켜진 뒤 켜진 채로 남는다. 58% 부터 70% 까지 위프로 쓸려 나가 원래 창이 드러나고,
// 바퀴가 돌아갈 동안 화면이 비는 순간은 없다.
.bs.is-demo .bs__screen {
visibility: visible; opacity: 1; pointer-events: none;
animation-name: bsCut, bsWipe;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
// 점 세 개 — 같은 키프레임을 음수 animation-delay 로 위상 이동해 차례로 켠다.
// 켜진 뒤에는 자기 위상의 50% 까지(=화면이 쓸려 나간 뒤까지) 켜진 채로 남는다
.bs.is-demo .bs__dot {
animation-name: bsDot;
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.bs.is-demo .bs__dot:nth-child(1) { animation-delay: -1600ms; }
.bs.is-demo .bs__dot:nth-child(2) { animation-delay: -1400ms; }
.bs.is-demo .bs__dot:nth-child(3) { animation-delay: -1200ms; }
// 등장 컷 — 중간 키프레임(7%)을 둬야 8% 지점에서 한 프레임에 바뀐다
@keyframes bsCut {
0%, 7% { opacity: 0; }
8%, 100% { opacity: 1; }
}
// 퇴장 위프 — steps 가 아니라 이징으로 보간되는 짝 애니메이션이다
@keyframes bsWipe {
0%, 58% { clip-path: inset(0 0 0 0); }
70%, 100% { clip-path: inset(100% 0 0 0); }
}
@keyframes bsDot {
0%, 50% { background: $stage-yellow; }
51%, 100% { background: rgba(255, 247, 230, .35); }
}
// ── 좁은 화면: 줄 글자를 한 단계 줄여 무대 안에 들어온다
@media (max-width: 340px) {
.bs { height: 168px; }
.bs__k, .bs__v { font-size: 11px; }
.bs__eline, .bs__gtext { font-size: 11px; }
.bs__err { margin-top: 0; }
.bs__body { padding: $sp-2; gap: 0; }
.bs__row + .bs__row { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.bs.is-demo .bs__screen, .bs.is-demo .bs__dot {
animation: none !important;
}
.bs.is-demo .bs__screen { opacity: 0 !important; visibility: hidden !important; clip-path: none !important; }
.bs.is-demo .bs__dot { background: rgba(255, 247, 230, .35) !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);
}
.bs {
position: relative;
box-sizing: border-box;
width: min(100%, 336px);
height: 196px;
max-height: calc(100vh - 32px);
border: 2px solid;
border-color: rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38) rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95);
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.bs button {
font-family: inherit;
cursor: pointer;
}
.bs button:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 1px;
}
.bs__win {
display: flex;
flex-direction: column;
height: 100%;
}
.bs__title {
display: flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 4px;
box-sizing: border-box;
background: #0a4fe8;
color: #fff7e6;
}
.bs__tico {
display: grid;
place-items: center;
}
.bs__tname {
flex: 1 1 auto;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
}
.bs__tbtns {
display: flex;
align-items: center;
gap: 4px;
}
.bs__gb {
width: 14px;
height: 13px;
box-sizing: border-box;
border: 2px solid rgba(255, 247, 230, 0.85);
}
.bs__gb--min {
border: 0;
border-bottom: 2px solid rgba(255, 247, 230, 0.85);
width: 12px;
align-self: flex-end;
}
.bs__body {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
}
.bs__row {
margin: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.bs__k {
font-weight: 500;
font-size: 12px;
line-height: 1.5;
color: rgba(23, 20, 26, 0.72);
}
.bs__v {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 12px;
line-height: 1.5;
color: #17141a;
}
.bs__actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.bs__btn {
min-width: 96px;
height: 26px;
box-sizing: border-box;
padding: 0 8px;
background: #fff7e6;
color: #17141a;
border: 2px solid;
border-color: rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38) rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95);
font-weight: 700;
font-size: 11px;
line-height: 1.4;
transition: transform 220ms ease-out;
}
.bs__btn:active {
border-color: rgba(23, 20, 26, 0.38) rgba(255, 255, 255, 0.95) rgba(255, 255, 255, 0.95) rgba(23, 20, 26, 0.38);
transform: translateY(1px);
}
.bs__screen {
position: absolute;
inset: 0;
z-index: 2;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
background: #0a4fe8;
clip-path: inset(0 0 0 0);
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.bs__screen.is-crash {
opacity: 1;
visibility: visible;
pointer-events: auto;
transition: clip-path 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bs__screen.is-crash.is-wiped {
clip-path: inset(100% 0 0 0);
}
.bs__err {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.bs__eline {
margin: 0;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 600;
font-size: 12px;
line-height: 1.5;
color: #fff7e6;
}
.bs__guide {
display: flex;
align-items: center;
gap: 8px;
margin-top: auto;
}
.bs__gtext {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 600;
font-size: 12px;
line-height: 1.5;
color: #fff7e6;
}
.bs__dots {
display: flex;
align-items: center;
gap: 4px;
}
.bs__dot {
width: 8px;
height: 8px;
background: rgba(255, 247, 230, 0.35);
}
.bs__sact {
display: flex;
justify-content: center;
}
.bs.is-demo .bs__screen {
visibility: visible;
opacity: 1;
pointer-events: none;
animation-name: bsCut, bsWipe;
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;
}
.bs.is-demo .bs__dot {
animation-name: bsDot;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.bs.is-demo .bs__dot:nth-child(1) {
animation-delay: -1600ms;
}
.bs.is-demo .bs__dot:nth-child(2) {
animation-delay: -1400ms;
}
.bs.is-demo .bs__dot:nth-child(3) {
animation-delay: -1200ms;
}
@keyframes bsCut {
0%, 7% {
opacity: 0;
}
8%, 100% {
opacity: 1;
}
}
@keyframes bsWipe {
0%, 58% {
clip-path: inset(0 0 0 0);
}
70%, 100% {
clip-path: inset(100% 0 0 0);
}
}
@keyframes bsDot {
0%, 50% {
background: #ffd23f;
}
51%, 100% {
background: rgba(255, 247, 230, 0.35);
}
}
@media (max-width: 340px) {
.bs {
height: 168px;
}
.bs__k, .bs__v {
font-size: 11px;
}
.bs__eline, .bs__gtext {
font-size: 11px;
}
.bs__err {
margin-top: 0;
}
.bs__body {
padding: 8px;
gap: 0;
}
.bs__row + .bs__row {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.bs.is-demo .bs__screen, .bs.is-demo .bs__dot {
animation: none !important;
}
.bs.is-demo .bs__screen {
opacity: 0 !important;
visibility: hidden !important;
clip-path: none !important;
}
.bs.is-demo .bs__dot {
background: rgba(255, 247, 230, 0.35) !important;
}
}var root = document.getElementById('bs');
var screen = document.getElementById('screen');
var crash = document.getElementById('crash');
var restart = document.getElementById('restart');
// 사람 손이 닿으면 격자 미리보기 루프를 끊고 실제 조작 경로로 돌아간다
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
// 시스템 오류 — 파란 화면이 한 프레임에 통째로 뜬다(전환이라 트랜지션 없이 클래스만)
crash.addEventListener('click', function () {
screen.classList.remove('is-wiped');
screen.classList.add('is-crash');
restart.focus({ preventScroll: true });
});
// 재시작 — 화면이 위에서 아래로 쓸려 사라지고 원래 창이 드러난다
restart.addEventListener('click', function () { screen.classList.add('is-wiped'); });
screen.addEventListener('transitionend', function (e) {
if (e.propertyName !== 'clip-path') return;
screen.classList.remove('is-crash', 'is-wiped');
});// BlueScreen — vanilla 판(run/023/demo/09-os-bluescreen-reboot/index.html) 의 React 포트.
// 오류 문구는 steps(1, end) 컷으로 한 프레임에 통째로 나타나고(중간 키프레임 7%·8% 가 컷 지점),
// 안내 줄의 점 세 개는 음수 animation-delay 로 위상을 밀어 차례로 켜진 뒤 켜진 채로 남는다.
// 재시작을 누르면 clip-path 위프(이징 짝 애니메이션)로 위에서 아래로 쓸려 사라지고 원래 창이
// 드러난다 — 퇴장이 주제여도 루프에서 화면이 비는 순간은 없다.
import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BlueScreen.module.scss";
export type BlueScreenProps = {
duration?: number;
easing?: string;
color?: string;
winTitle?: string;
crashText?: string;
restartText?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 타입에 TransitionEvent 가 없다 — 쓰는 속성만 로컬 타입으로 선언한다
type TransEvt = { propertyName: string };
// useRef<T> 는 { current: T | null } 을 돌려준다 — ambient 타입에 맞춘 상자 타입
type Box<T> = { current: T | null };
const WIN_TITLE = "시스템 정보";
const CRASH = "시스템 오류";
const RESTART = "재시작";
const ROWS: { k: string; v: string }[] = [
{ k: "프로세서", v: "486DX2 66MHz" },
{ k: "메모리", v: "16 MB" },
{ k: "디스크", v: "540 MB" },
];
const ERR_LINES = [
"치명적 오류 0E : 016F : BFF9B3D4",
"VXD VMM(01) 을 처리하다 문제가 생겼습니다",
"현재 작업을 저장하고 시스템을 다시 시작합니다",
];
const GUIDE = "상태를 저장하는 중";
export default function BlueScreen(props: BlueScreenProps) {
const duration = props.duration ?? 600;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#0a4fe8";
const winTitle = props.winTitle ?? WIN_TITLE;
const crashText = props.crashText ?? CRASH;
const restartText = props.restartText ?? RESTART;
const [preview, setPreview] = useState<boolean>(true);
const [crashed, setCrashed] = useState<boolean>(false);
const [wiped, setWiped] = useState<boolean>(false);
const restartRef = useRef<HTMLButtonElement>(null) as Box<HTMLButtonElement>;
const leave = () => setPreview(false);
// 시스템 오류 — 파란 화면이 한 프레임에 통째로 뜬다(전환이라 트랜지션 없이 상태만).
// 포커스는 iframe 안 데모라 부모 스크롤을 밀지 않도록 preventScroll 과 함께
const onCrash = () => {
setWiped(false);
setCrashed(true);
if (restartRef.current) restartRef.current.focus({ preventScroll: true });
};
// 재시작 — 화면이 위에서 아래로 쓸려 사라지고 원래 창이 드러난다
const onRestart = () => setWiped(true);
const onWipeEnd = (e: TransEvt) => {
if (e.propertyName !== "clip-path") return;
setCrashed(false);
setWiped(false);
};
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={leave}
>
<div className={styles.win}>
<div className={styles.title}>
<span className={styles.tico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M128 144h80v72l-80-14.55Zm-96 40l64 11.64V144H32Zm96-129.45V112h80V40ZM32 112h64V60.36L32 72Z" opacity=".2" />
<path d="M208 136h-80a8 8 0 0 0-8 8v57.45a8 8 0 0 0 6.57 7.88l80 14.54a7.6 7.6 0 0 0 1.43.13a8 8 0 0 0 8-8v-72a8 8 0 0 0-8-8m-8 70.41l-64-11.63V152h64ZM96 136H32a8 8 0 0 0-8 8v40a8 8 0 0 0 6.57 7.87l64 11.64a8.5 8.5 0 0 0 1.43.13a8 8 0 0 0 8-8V144a8 8 0 0 0-8-8m-8 50.05l-48-8.73V152h48ZM213.13 33.86a8 8 0 0 0-6.56-1.73l-80 14.55a8 8 0 0 0-6.57 7.87V112a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8V40a8 8 0 0 0-2.87-6.14M200 104h-64V61.22l64-11.63Zm-98.87-49.78a8 8 0 0 0-6.56-1.73l-64 11.64A8 8 0 0 0 24 72v40a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8V60.36a8 8 0 0 0-2.87-6.14M88 104H40V78.68L88 70Z" />
</g>
</svg>
</span>
<span className={styles.tname}>{winTitle}</span>
<span className={styles.tbtns} aria-hidden="true">
<span className={styles.gb + " " + styles.gbMin} />
<span className={styles.gb} />
</span>
</div>
<div className={styles.wbody}>
{ROWS.map((row, i) => (
<p key={i} className={styles.row + (i > 0 ? " " + styles.rowGap : "")}>
<span className={styles.k}>{row.k}</span>
<span className={styles.v}>{row.v}</span>
</p>
))}
<div className={styles.actions}>
<button type="button" className={styles.btn} onClick={onCrash}>
{crashText}
</button>
</div>
</div>
</div>
<div
className={
styles.screen +
(crashed ? " " + styles.isCrash : "") +
(wiped ? " " + styles.isWiped : "")
}
onTransitionEnd={onWipeEnd}
>
<div className={styles.err}>
{ERR_LINES.map((text, i) => (
<p key={i} className={styles.eline}>
{text}
</p>
))}
</div>
<div className={styles.guide}>
<span className={styles.gtext}>{GUIDE}</span>
<span className={styles.dots} aria-hidden="true">
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</span>
</div>
<div className={styles.sact}>
<button
type="button"
className={styles.btn + " " + styles.btnS}
ref={restartRef}
onClick={onRestart}
>
{restartText}
</button>
</div>
</div>
</div>
</main>
);
}09 · 파란 오류 화면과 재시작 — 측정 누적면적 36.953% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 등장 컷 — 중간 키프레임(7%)을 둬야 8% 지점에서 한 프레임에 바뀐다
@keyframes bsCut {
0%, 7% { opacity: 0; }
8%, 100% { opacity: 1; }
}
// 퇴장 위프 — steps 가 아니라 이징으로 보간되는 짝 애니메이션이다
@keyframes bsWipe {
0%, 58% { clip-path: inset(0 0 0 0); }
70%, 100% { clip-path: inset(100% 0 0 0); }
}
측정에서도 가장 무거운 판
누적면적 36.953% 와 면적 최대 36.953% 가 같은 숫자라는 것은 움직임 전부가 파란 면이 창을 덮는 한 번의 전환에서 왔다는 뜻이니, 치명적 오류를 크게 알리는 화면으로는 아홉 개 중 가장 무겁습니다.
crt 모니터 효과는 CSS 만으로 어디까지 되나?
줄무늬는 타일 하나가 전부입니다
스캔라인의 두께·간격·색은 4px 타일 하나가 전부 정하고, background-size 로 타일 크기를 박아 두면 화면이 아무리 커져도 무늬가 늘지 않고 칸 수만 늘어납니다. 08 의 칸 막대 도안도 같은 법칙인데, 그라디언트 정지점은 픽셀이 아니라 타일 안 비율로 주어야 합니다 — 픽셀로 주면 타일이 아니라 요소 전체 폭을 한 번에 까는 그라디언트가 되어 칸무늬가 사라집니다.
굽은 유리는 어둠의 겹침으로 접습니다
볼록한 유리는 실제로 화면을 왜곡하지 않습니다. radial-gradient 타원으로 가장자리를 어둡게 깔면 눈이 스스로 곡면이라 읽고(02), 형광체의 빛 번짐은 그림이 아니라 text-shadow 한 줄, 위에서 내려오는 역광은 반투명 띠의 세로 흐름으로 흉내 냈습니다. 실제 브라운관의 배럴 왜곡처럼 화면 끝이 휘어 보이는 기하 왜곡까지 내려면 CSS 만으로는 부족해 SVG 필터나 캔버스가 필요한데, 이 아홉 개는 그 지점 전의 질감까지만 노립니다.
그림 파일이 한 장도 없습니다
아홉 개를 통틀어 이미지 파일은 0장입니다. 픽셀 커서(03)는 box-shadow 픽셀 도화, 체커보드 격자는 conic-gradient, 모니터 껍데기는 색면과 그림자라서, 그림이 필요한 자리를 전부 값으로 바꿨습니다. 받은 폴더를 열면 코드만 있고 픽셀 도화는 배율을 바꿔도 다시 그려 흐려지지 않으며, 화면 바탕의 짙은 남색(#0d1220)만 토큰 밖의 색인데 브라운관 형광면 바탕은 기존 팔레트에 없는 색이라 한 곳에 직접 지정했습니다.
어디서 깨지는가 — 함정
두 키프레임짜리 컷은 보이지 않습니다
steps(1, end) 컷에 0%{숨김} 100%{보임} 두 키프레임만 넣으면 값이 구간의 맨 끝에서만 바뀌어 2초 내내 숨김 장만 보입니다. 컷이 루프가 닫히는 바로 그 순간에 일어나 화면 안에서는 볼 수 없는 것이라, 09 의 파란 화면은 컷 지점을 7%·8% 중간 키프레임 쌍으로 두어야 한 프레임에 떴습니다. 상태가 둘뿐인 다른 판도 같은 이유로 중간 키프레임을 두었습니다 — 01 의 작업표시줄 칸은 50%·51%·85%·86% 지점에, 07 의 알림 종은 15%·16%·79%·80% 에 컷을 걸었습니다.
짧아서 편한 단축이 지연을 지웁니다
animation 단축 속성을 쓰는 순간 animation-delay 는 0 으로 되돌아갑니다. 줄마다 박자가 다른 04 같은 판에서 단축을 다시 쓰면 지연이 사라져 다섯 줄이 한꺼번에 쏟아지므로, 이 아홉 개에서 지연을 쓰는 곳(08 의 숫자 장, 09 의 점 세 개)은 이름·길이·타이밍을 전부 낱개 속성으로 적습니다. 여기 아홉 개의 완결본과 React 판을 묶은 zip 은 sqx22eqz 를 암호로 열고, 편집기 없이 브라우저에서 바로 열어 쓸 수 있습니다.
변형 세 가지와 접근성
변수 세 개로 아홉 개를 함께 바꾸기
각 데모의 SCSS 맨 위에 있는 변수 몇 개만 바꾸면 무대 전체가 따라옵니다. 변수는 데모마다 한 벌씩이라 파일 하나만 손대고 나머지 여덟은 그대로 두고 비교해 볼 수 있습니다.
| 이름 | 바꾸는 값 | 느낌 |
|---|---|---|
| duration | $dur-loop 2s → 1s |
부팅 줄과 설치 칸의 박자가 두 배 빨라집니다 |
| easing | $ease-spring → $ease-pop |
창이 접히고 오류 화면이 쓸릴 때 한 번 더 튑니다 |
| color | $color $subject-blue-deep → $subject-orange-deep |
선택 색과 타이틀바가 파란 계열에서 주황 계열로 갑니다 |
움직임을 줄이면 남는 것들
prefers-reduced-motion: reduce 에서는 아홉 개가 각각 다른 한 장을 남깁니다. 01 은 접히지 않은 창 한 장, 02 는 줄무늬와 비네트(띠만 멈춥니다), 03 은 화살표 커서, 04 는 점검 줄이 다 찬 화면, 05 는 첫 아이콘이 선택되고 창이 열린 모습, 06 은 메뉴가 열린 채 하위를 감춘 모습, 07 은 실제 시각을 보여 주는 시계, 08 은 막대가 다 찬 완료 상태, 09 는 오류 화면을 지운 원래 창입니다. 그 블록의 선언마다 !important 를 단 것은 .is-demo 가 붙은 선택자가 특이도에서 먼저 이기기 때문입니다. 07 의 시계는 루프를 꺼도 tick 이 실제 시각을 계속 갱신해 멈춘 화면이 낡은 시각을 보여 주는 일이 없습니다.
키보드와 색 이외의 신호
마우스 없이도 씁니다 — 01 타이틀바는 방향키로 옮기고(Shift 를 함께 누르면 16px) Escape 로 접고, 05 는 listbox 안에서 방향키와 Enter, 06 은 방향키로 줄을 옮기고 Escape 로 닫으며, 09 는 오류 화면이 뜨는 순간 재시작 단추에 초점을 옮겨 둡니다. 색도 유일한 신호가 아닙니다 — 07 의 눌림은 테두리 방향 뒤집힘과 글자 굵기·한 칸 내려앉음이 함께 오고, 05 의 선택은 aria-selected 와 이름표 배경이 같이 바뀌며, 03 은 커서 모양 옆에 이름표를 적어 둡니다.
FAQ
crt 모니터 효과를 실제 사진이나 영상 위에 얹어도 되나요
됩니다. 줄무늬·비네트·흐르는 띠는 position: absolute; inset: 0 에 pointer-events: none 인 겹침층이라 아래에 깔리는 콘텐츠를 가리지도 누르지도 않습니다. 02 의 화면 상자처럼 overflow: hidden 인 껍데기 하나만 만들어 주면 사진·영상·캔버스 위에도 그대로 얹히고, 질감이 강하다면 그림자 색의 불투명도부터 낮춰 보세요. 줄무늬가 진하다고 느껴지면 스캔라인 첫 색의 .38 부터 내리면 눈의 부담이 바로 줄어듭니다.
모바일에서도 창 끌기와 두 번 누르기가 되나요
둘 다 됩니다. 01 의 타이틀바는 touch-action: none 을 두고 pointer 이벤트로 끌기를 잡아 손가락이 창 밖으로 나가도 놓치지 않으며, 05 의 두 번 누르기는 터치에서 e.detail 이 항상 1 이라 400ms 안에 같은 아이콘을 다시 누르는지로 판정합니다. 340px 보다 좁은 화면에서는 각 데모가 스스로 줄어들고, 05 는 세로로 늘어선 아이콘 줄이 가로로 눕습니다.
픽셀 폰트를 따로 내려받아야 하나요
아니요. 아홉 개 전부 시스템의 모노스페이스 스택(ui-monospace 계열) 하나로 짰고 웹폰트는 한 벌도 쓰지 않습니다. 픽셀 느낌이 필요한 자리(03 의 커서)는 글꼴이 아니라 box-shadow 픽셀 도화라서 확대해도 흐려지지 않고, 시스템 글꼴이라 첫 화면이 그려지기까지 기다림도 짧아서 대기 화면에 알맞습니다. 모노스페이스를 고른 까닭은 점검 줄·남은 용량·시계처럼 자릿수가 어긋나면 안 되는 글자가 많아서인데, 가변 폭 글꼴로 바꾸면 숫자가 흔들려 옛 화면의 정돈된 느낌이 사라집니다.









