06시작 메뉴 펼침
왼쪽 세로 띠가 붙은 메뉴가 아래에서 위로 열리고, 화살표가 있는 줄에 올리면 하위 메뉴가 오른쪽으로 이어 열립니다. 열린 방향이 화면 밖으로 나가면 왼쪽으로 접어 넣습니다.
쓰임항목이 많은 관리 화면의 메뉴
aria-expandedwriting-modetransform-origin

<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 포함 · 윈도우 98 디자인 9종 — crt 모니터 효과까지 복붙
이 데모가 속한 글
윈도우 98 디자인 9종 — crt 모니터 효과까지 복붙윈도우 98 디자인은 지금 웹에서도 가장 알아보기 쉬운 레트로 문법입니다. 여기 아홉 개는 그 시절 책상 위 화면을 통째로 옮긴 부품인데, 창을 끌고 최소화까지 진짜로 됩니다. 스캔라인이 흐르는 브라운관, 픽셀 커서 이 글의 데모 전부 보기


