07작업표시줄 시계와 알림 칸
오른쪽 끝 시계가 실제 시각을 분 단위로 세고, 옆 칸의 작은 표시가 켜지면 그 위에 말풍선이 올라옵니다. 창 단추는 눌린 상태와 올라온 상태의 테두리 방향이 서로 반대입니다.
쓰임상태를 늘 보여줘야 하는 하단 바
toLocaleTimeStringinsetborder-color

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


