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 オリジナル · SCSS と React は zip に同梱 · css crt effect 9選 — レトロOS UI をコピペで
このデモが属する記事
css crt effect 9選 — レトロOS UI をコピペでcss crt effect は、Windows 98 の画面を HTML・SCSS・JS だけで組み直すレトロ UI の定番です。ここに並べた9つの部品はあの頃の机の上をそのまま再現していて、ウィンドウは本当に動いて最小化します。走査線の この記事のデモをすべて見る


