09放っておくと眠る待機
一定時間入力がないと目が閉じ、文字が一つずつ浮かびます。画面を押すとキャラクターが起き、タイマーが最初に戻ります。
使いどころ長く点けたままにするダッシュボードや案内画面で、画面が固まっていないことを示す合図
translateYanimation-delaysetTimeoutprefers-reduced-motion

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-moon" viewBox="0 0 256 256" fill="currentColor"><g><path d="M227.89 147.89A96 96 0 1 1 108.11 28.11a96.09 96.09 0 0 0 119.78 119.78" opacity=".2"/><path d="M233.54 142.23a8 8 0 0 0-8-2a88.08 88.08 0 0 1-109.8-109.8a8 8 0 0 0-10-10a104.84 104.84 0 0 0-52.91 37A104 104 0 0 0 136 224a103.1 103.1 0 0 0 62.52-20.88a104.84 104.84 0 0 0 37-52.91a8 8 0 0 0-1.98-7.98m-44.64 48.11A88 88 0 0 1 65.66 67.11a89 89 0 0 1 31.4-26A106 106 0 0 0 96 56a104.11 104.11 0 0 0 104 104a106 106 0 0 0 14.92-1.06a89 89 0 0 1-26.02 31.4"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="mb is-demo" id="mb">
<p class="mb__hint">
<svg class="mb__ico" aria-hidden="true"><use href="#ico-moon"/></svg>
<span>入力がないと眠り、押すと起きます</span>
</p>
<div class="mb__floor">
<!-- 캐릭터는 아이콘이 아니라 피사체라 손으로 그린다(01 과 같은 몸). Z 글자는 기호라 문구 사전이 필요 없다. -->
<div class="mb__char" aria-label="起きたり眠ったりするマスコット" role="img">
<span class="mb__body">
<i class="mb__eye mb__eye--l"></i>
<i class="mb__eye mb__eye--r"></i>
<i class="mb__cheek mb__cheek--l"></i>
<i class="mb__cheek mb__cheek--r"></i>
<i class="mb__mouth"></i>
</span>
<span class="mb__zLayer" aria-hidden="true">
<i class="mb__z mb__z--1" style="--i:0">Z</i>
<i class="mb__z mb__z--2" style="--i:1">Z</i>
<i class="mb__z mb__z--3" style="--i:2">Z</i>
</span>
<span class="mb__shadow"></span>
</div>
</div>
<p class="mb__state">
<i class="mb__dot" aria-hidden="true"></i>
<span class="mb__stateText" id="mzState">起きている</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 오래 두면 잠드는 유휴 — setTimeout 유휴 타이머가 지나면 눈이 감기고(steps 컷) Z 글자가 하나씩 떠오른다.
// 어떤 입력이든 타이머를 처음부터 되돌린다. 눈 감김을 opacity 크로스페이드로 하면 반투명 유령 프레임이
// 생기므로 steps(1, end) 컷으로 바꾸고, 움직임 프레임은 Z 상승(translateY)과 잠든 숨(transform)이 채운다.
// animation-delay 를 쓴 요소는 반드시 animation-fill-mode: backwards 와 함께 — 없으면 지연 구간에
// 기본 상태가 0프레임에 완성 상태로 번쩍인다(252-08 실측). animation: 단축은 delay 를 0으로 리셋하므로
// 낱개 속성만 쓴다(240 실측).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.mb { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; max-width: 320px; }
.mb__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-ink;
}
.mb__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.mb__floor { position: relative; width: 100%; height: 150px; display: grid; place-items: end center; }
.mb__char { position: relative; display: block; width: 136px; height: 132px; }
// 몸 — 타원 하나(01 과 같은 몸). 잠든 숨은 이 요소의 scale 로만 준다.
// 발이 바닥에 붙어 눌려 자는 자세라 transform-origin 은 바닥(50% 100%)이다(blog-demo 규칙 8 의 예외).
.mb__body {
position: absolute; left: 50%; bottom: 8px; display: block; width: 88px; height: 96px;
margin-left: -44px;
border-radius: 50% 50% 46% 46%;
background: $color;
transform-origin: 50% 100%;
box-shadow: $shadow-press;
}
.mb__eye {
position: absolute; top: 40px; width: 10px; height: 12px;
border-radius: 50%; background: $subject-cream;
transform-origin: 50% 50%;
}
.mb__eye--l { left: 22px; }
.mb__eye--r { right: 22px; }
// 눈 감김은 컷 — 반쯤 감긴 유령 프레임을 만들지 않는다
.mb.is-asleep .mb__eye { transform: scaleY(.1); }
.mb__cheek {
position: absolute; top: 56px; width: 12px; height: 6px;
border-radius: $r-pill; background: $subject-pink; opacity: .9;
}
.mb__cheek--l { left: 14px; }
.mb__cheek--r { right: 14px; }
.mb__mouth {
position: absolute; top: 60px; left: 50%; width: 16px; height: 8px;
margin-left: -8px;
border-bottom: 3px solid $subject-cream;
border-radius: 0 0 $r-pill $r-pill;
}
.mb__shadow {
position: absolute; left: 50%; bottom: 4px; width: 72px; height: 10px;
margin-left: -36px;
border-radius: 50%;
background: rgba(23, 20, 26, .18);
}
.mb.is-asleep .mb__shadow { transform: scaleX(1.12); }
// Z 글자 — 한국어가 아니라 기호라 문구 사전이 필요 없다. 머리 오른쪽 위로 하나씩.
// 기본은 opacity 0(깨어 있을 때 안 보임), 잠들었을 때만 애니메이션이 켜진다.
.mb__zLayer { position: absolute; top: 0; right: -24px; width: 72px; height: 112px; pointer-events: none; }
.mb__z {
position: absolute; opacity: 0; color: $subject-ink;
font-weight: 800; font-size: 13px; line-height: 1;
transform-origin: 50% 50%;
}
.mb__z--1 { left: 0; top: 52px; font-size: 13px; }
.mb__z--2 { left: 26px; top: 30px; font-size: 17px; }
.mb__z--3 { left: 52px; top: 10px; font-size: 21px; }
.mb__state { display: flex; align-items: center; gap: $sp-2; margin: 0; }
.mb__dot {
display: block; width: 8px; height: 8px; border-radius: 50%;
background: $subject-blue; // 옐로 위 파란 점 3.1:1 — 글자가 아니라 상태 면이라 그래픽 기준(3:1)
}
.mb.is-asleep .mb__dot { background: $subject-ink; }
.mb__stateText { font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-ink; }
// ── 실사용 경로(잠들었을 때) ────────────────────────────────
// 잠든 숨은 느리게, Z 는 떠오르다가 사라지기를 반복한다 — reduce 에서는 아래 블록이 이걸 멈춘다.
.mb.is-asleep .mb__body {
animation-name: mzBreathSleep;
animation-duration: 3.6s;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mb.is-asleep .mb__z {
animation-name: mzZFloat;
animation-duration: 2.8s;
animation-delay: calc(var(--i) * .46s);
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards; // 지연 구간에는 0% 상태(안 보임)를 채운다
}
// 깨는 순간 팝 — 한 번만 재생되고 JS 가 460ms 뒤에 클래스를 뗀다
.mb.is-wake .mb__body {
animation-name: mzWakePop;
animation-duration: 460ms;
animation-timing-function: $ease-pop;
}
// ── 격자 미리보기 루프 ──────────────────────────────────────
// 2초 안에 「깨어 있는 숨 → 눈 감김(컷) → 잠든 숨 → Z 상승」이 다 보이게. 복귀는 90% 안에.
@keyframes mzBodyLoop {
0% { transform: scale(1, 1); }
8% { transform: scale(.99, 1.015); }
16% { transform: scale(1, 1); }
22% { transform: scale(1.06, .93); }
42% { transform: scale(1.03, .965); }
68% { transform: scale(1.06, .93); }
88%, 100% { transform: scale(1.015, .99); }
}
@keyframes mzLidLoop {
0%, 16% { transform: scaleY(1); }
18%, 100% { transform: scaleY(.1); }
}
@keyframes mzShadowLoop {
0% { transform: scaleX(1); }
8% { transform: scaleX(.97); }
16% { transform: scaleX(1); }
22% { transform: scaleX(1.12); }
42% { transform: scaleX(1.06); }
68% { transform: scaleX(1.12); }
88%, 100% { transform: scaleX(1.03); }
}
@keyframes mzZLoop {
0% { transform: translateY(8px) scale(.4); opacity: 0; }
12% { opacity: 1; }
78% { transform: translateY(-28px) scale(1); }
100% { transform: translateY(-34px) scale(1); opacity: 1; } // 루프 안에서 사라지지 않게 떠 있게 끝낸다
}
@keyframes mzBreathSleep {
0%, 100% { transform: scale(1.045, .955); }
50% { transform: scale(1.02, .985); }
}
@keyframes mzZFloat {
0% { transform: translateY(8px) scale(.5); opacity: 0; }
14% { opacity: 1; }
72% { opacity: 1; }
100% { transform: translateY(-38px) scale(1); opacity: 0; }
}
@keyframes mzWakePop {
0% { transform: scale(1.045, .955); }
45% { transform: scale(.97, 1.05); }
100% { transform: scale(1, 1); }
}
.mb.is-demo .mb__body {
animation-name: mzBodyLoop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mb.is-demo .mb__eye {
animation-name: mzLidLoop;
animation-duration: $duration;
animation-timing-function: steps(1, end); // 눈 감김은 한 프레임 컷
animation-iteration-count: infinite;
}
.mb.is-demo .mb__shadow {
animation-name: mzShadowLoop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mb.is-demo .mb__z {
animation-name: mzZLoop;
animation-duration: $duration;
animation-delay: calc(var(--i) * 240ms);
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@media (max-width: 340px) {
.mb { gap: $sp-2; }
.mb__floor { height: 118px; }
.mb__char { width: 120px; height: 112px; }
.mb__body { width: 80px; height: 84px; margin-left: -40px; }
.mb__eye { top: 36px; width: 9px; height: 11px; }
.mb__eye--l { left: 20px; }
.mb__eye--r { right: 20px; }
.mb__cheek { top: 50px; }
.mb__mouth { top: 54px; }
.mb__shadow { width: 64px; margin-left: -32px; }
.mb__zLayer { right: -20px; width: 64px; height: 96px; }
.mb__z--1 { left: 0; top: 44px; font-size: 12px; }
.mb__z--2 { left: 22px; top: 26px; font-size: 15px; }
.mb__z--3 { left: 44px; top: 8px; font-size: 18px; }
}
// reduce — 유휴 타이머가 만든 움직임을 멈추고 잠든 상태를 정지 한 장으로 보여준다(WCAG 2.2.2).
// 떠오르는 Z 는 떠오른 위치에 고정. 상태 바뀜(눈 감김)은 컷이라 그대로 둬도 정지 장면이다.
@media (prefers-reduced-motion: reduce) {
.mb.is-demo .mb__body,
.mb.is-demo .mb__eye,
.mb.is-demo .mb__shadow,
.mb.is-demo .mb__z,
.mb.is-asleep .mb__body,
.mb.is-asleep .mb__z,
.mb.is-wake .mb__body { animation: none !important; }
.mb.is-demo .mb__body { transform: scale(1.045, .955) !important; }
.mb.is-demo .mb__eye { transform: scaleY(.1) !important; }
.mb.is-demo .mb__shadow { transform: scaleX(1.12) !important; }
.mb.is-demo .mb__z { opacity: 1 !important; transform: translateY(-26px) scale(1) !important; }
.mb.is-asleep .mb__body { transform: scale(1.045, .955) !important; }
.mb.is-asleep .mb__shadow { transform: scaleX(1.12) !important; }
.mb.is-asleep .mb__z { opacity: 1 !important; transform: translateY(-26px) scale(1) !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);
}
.mb {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 320px;
}
.mb__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.mb__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.mb__floor {
position: relative;
width: 100%;
height: 150px;
display: grid;
place-items: end center;
}
.mb__char {
position: relative;
display: block;
width: 136px;
height: 132px;
}
.mb__body {
position: absolute;
left: 50%;
bottom: 8px;
display: block;
width: 88px;
height: 96px;
margin-left: -44px;
border-radius: 50% 50% 46% 46%;
background: #17141a;
transform-origin: 50% 100%;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.mb__eye {
position: absolute;
top: 40px;
width: 10px;
height: 12px;
border-radius: 50%;
background: #fff7e6;
transform-origin: 50% 50%;
}
.mb__eye--l {
left: 22px;
}
.mb__eye--r {
right: 22px;
}
.mb.is-asleep .mb__eye {
transform: scaleY(0.1);
}
.mb__cheek {
position: absolute;
top: 56px;
width: 12px;
height: 6px;
border-radius: 999px;
background: #ff9ebb;
opacity: 0.9;
}
.mb__cheek--l {
left: 14px;
}
.mb__cheek--r {
right: 14px;
}
.mb__mouth {
position: absolute;
top: 60px;
left: 50%;
width: 16px;
height: 8px;
margin-left: -8px;
border-bottom: 3px solid #fff7e6;
border-radius: 0 0 999px 999px;
}
.mb__shadow {
position: absolute;
left: 50%;
bottom: 4px;
width: 72px;
height: 10px;
margin-left: -36px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.18);
}
.mb.is-asleep .mb__shadow {
transform: scaleX(1.12);
}
.mb__zLayer {
position: absolute;
top: 0;
right: -24px;
width: 72px;
height: 112px;
pointer-events: none;
}
.mb__z {
position: absolute;
opacity: 0;
color: #17141a;
font-weight: 800;
font-size: 13px;
line-height: 1;
transform-origin: 50% 50%;
}
.mb__z--1 {
left: 0;
top: 52px;
font-size: 13px;
}
.mb__z--2 {
left: 26px;
top: 30px;
font-size: 17px;
}
.mb__z--3 {
left: 52px;
top: 10px;
font-size: 21px;
}
.mb__state {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.mb__dot {
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #2f6df6;
}
.mb.is-asleep .mb__dot {
background: #17141a;
}
.mb__stateText {
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.mb.is-asleep .mb__body {
animation-name: mzBreathSleep;
animation-duration: 3.6s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mb.is-asleep .mb__z {
animation-name: mzZFloat;
animation-duration: 2.8s;
animation-delay: calc(var(--i) * 0.46s);
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mb.is-wake .mb__body {
animation-name: mzWakePop;
animation-duration: 460ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes mzBodyLoop {
0% {
transform: scale(1, 1);
}
8% {
transform: scale(0.99, 1.015);
}
16% {
transform: scale(1, 1);
}
22% {
transform: scale(1.06, 0.93);
}
42% {
transform: scale(1.03, 0.965);
}
68% {
transform: scale(1.06, 0.93);
}
88%, 100% {
transform: scale(1.015, 0.99);
}
}
@keyframes mzLidLoop {
0%, 16% {
transform: scaleY(1);
}
18%, 100% {
transform: scaleY(0.1);
}
}
@keyframes mzShadowLoop {
0% {
transform: scaleX(1);
}
8% {
transform: scaleX(0.97);
}
16% {
transform: scaleX(1);
}
22% {
transform: scaleX(1.12);
}
42% {
transform: scaleX(1.06);
}
68% {
transform: scaleX(1.12);
}
88%, 100% {
transform: scaleX(1.03);
}
}
@keyframes mzZLoop {
0% {
transform: translateY(8px) scale(0.4);
opacity: 0;
}
12% {
opacity: 1;
}
78% {
transform: translateY(-28px) scale(1);
}
100% {
transform: translateY(-34px) scale(1);
opacity: 1;
}
}
@keyframes mzBreathSleep {
0%, 100% {
transform: scale(1.045, 0.955);
}
50% {
transform: scale(1.02, 0.985);
}
}
@keyframes mzZFloat {
0% {
transform: translateY(8px) scale(0.5);
opacity: 0;
}
14% {
opacity: 1;
}
72% {
opacity: 1;
}
100% {
transform: translateY(-38px) scale(1);
opacity: 0;
}
}
@keyframes mzWakePop {
0% {
transform: scale(1.045, 0.955);
}
45% {
transform: scale(0.97, 1.05);
}
100% {
transform: scale(1, 1);
}
}
.mb.is-demo .mb__body {
animation-name: mzBodyLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mb.is-demo .mb__eye {
animation-name: mzLidLoop;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mb.is-demo .mb__shadow {
animation-name: mzShadowLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mb.is-demo .mb__z {
animation-name: mzZLoop;
animation-duration: 2s;
animation-delay: calc(var(--i) * 240ms);
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@media (max-width: 340px) {
.mb {
gap: 8px;
}
.mb__floor {
height: 118px;
}
.mb__char {
width: 120px;
height: 112px;
}
.mb__body {
width: 80px;
height: 84px;
margin-left: -40px;
}
.mb__eye {
top: 36px;
width: 9px;
height: 11px;
}
.mb__eye--l {
left: 20px;
}
.mb__eye--r {
right: 20px;
}
.mb__cheek {
top: 50px;
}
.mb__mouth {
top: 54px;
}
.mb__shadow {
width: 64px;
margin-left: -32px;
}
.mb__zLayer {
right: -20px;
width: 64px;
height: 96px;
}
.mb__z--1 {
left: 0;
top: 44px;
font-size: 12px;
}
.mb__z--2 {
left: 22px;
top: 26px;
font-size: 15px;
}
.mb__z--3 {
left: 44px;
top: 8px;
font-size: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.mb.is-demo .mb__body,
.mb.is-demo .mb__eye,
.mb.is-demo .mb__shadow,
.mb.is-demo .mb__z,
.mb.is-asleep .mb__body,
.mb.is-asleep .mb__z,
.mb.is-wake .mb__body {
animation: none !important;
}
.mb.is-demo .mb__body {
transform: scale(1.045, 0.955) !important;
}
.mb.is-demo .mb__eye {
transform: scaleY(0.1) !important;
}
.mb.is-demo .mb__shadow {
transform: scaleX(1.12) !important;
}
.mb.is-demo .mb__z {
opacity: 1 !important;
transform: translateY(-26px) scale(1) !important;
}
.mb.is-asleep .mb__body {
transform: scale(1.045, 0.955) !important;
}
.mb.is-asleep .mb__shadow {
transform: scaleX(1.12) !important;
}
.mb.is-asleep .mb__z {
opacity: 1 !important;
transform: translateY(-26px) scale(1) !important;
}
}// 유휴 타이머 — setTimeout 으로 데모에서는 짧게(3.6초) 재운다. 어떤 입력(pointerdown·keydown)이든
// clearTimeout 후 타이머를 처음부터 되돌리고, 자고 있으면 깨운다(.is-wake 팝 한 번).
// 격자 미리보기(.is-demo)가 돌고 있는 동안은 실제 타이머를 돌리지 않는다 — 미리보기가 잠드는 이야기를
// 대신 보여 주고, 사람이 처음 손을 댄 뒤부터 실제 유휴 타이머가 돈다(01 과 같은 넘김 구조).
(function () {
var root = document.getElementById('mb');
var state = document.getElementById('mzState');
var AWAKE = '起きている';
var ASLEEP = '眠っている';
var IDLE_MS = 3600;
var timer = null;
function sleep() { root.classList.add('is-asleep'); state.textContent = ASLEEP; }
function arm() { clearTimeout(timer); timer = setTimeout(sleep, IDLE_MS); }
function wake() {
var was = root.classList.contains('is-asleep');
root.classList.remove('is-demo');
if (was) {
root.classList.remove('is-asleep');
root.classList.add('is-wake');
setTimeout(function () { root.classList.remove('is-wake'); }, 460);
state.textContent = AWAKE;
}
arm();
}
document.addEventListener('pointerdown', wake);
window.addEventListener('keydown', wake);
if (!root.classList.contains('is-demo')) arm();
}());// SleepIdle — vanilla 판(run/300/demo/09-mascot-sleep-idle/index.html) 의 React 포트.
// setTimeout 유휴 타이머(3.6초) → 잠듦, 어떤 입력이든 타이머를 처음부터 되돌린다. 값은 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SleepIdle.module.scss";
export type SleepIdleProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ZVars = CSSProperties & { "--i"?: string };
const IDLE_MS = 3600;
const HINT_TEXT = "아무 입력이 없으면 잠들고, 누르면 깨어납니다";
const CHAR_LABEL = "깨었다 잠드는 마스코트";
const AWAKE_TEXT = "깨어 있음";
const ASLEEP_TEXT = "자는 중";
export default function SleepIdle({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: SleepIdleProps) {
const [asleep, setAsleep] = useState(false);
const [demo, setDemo] = useState(true);
const [wakePop, setWakePop] = useState(false);
const timer = useRef(0);
const popTimer = useRef(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function arm() {
if (timer.current) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setAsleep(true), IDLE_MS);
}
function wake() {
setDemo(false);
if (asleep) {
setAsleep(false);
setWakePop(true);
if (popTimer.current) window.clearTimeout(popTimer.current);
popTimer.current = window.setTimeout(() => setWakePop(false), 460);
}
arm();
}
// 창 키 입력으로도 깨운다(포인터는 최상위 onPointerDown 이 담당). 타이머는 cleanup 에서 정리.
// 격자 미리보기(demo) 동안은 실제 타이머를 돌리지 않는다 — 처음 손을 댄 뒤부터 돈다(vanilla 와 같은 구조).
useEffect(() => {
function onKey() { wake(); }
window.addEventListener("keydown", onKey);
if (!demo) arm();
return () => {
window.removeEventListener("keydown", onKey);
if (timer.current) window.clearTimeout(timer.current);
if (popTimer.current) window.clearTimeout(popTimer.current);
};
});
const cls = [styles.root, demo ? styles.isDemo : "", asleep ? styles.isAsleep : "", wakePop ? styles.isWake : ""]
.filter(Boolean).join(" ");
const zCls = [styles.z1, styles.z2, styles.z3];
return (
<main className={styles.stage} onPointerDown={wake}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-moon" viewBox="0 0 256 256" fill="currentColor"><g><path d="M227.89 147.89A96 96 0 1 1 108.11 28.11a96.09 96.09 0 0 0 119.78 119.78" opacity=".2"/><path d="M233.54 142.23a8 8 0 0 0-8-2a88.08 88.08 0 0 1-109.8-109.8a8 8 0 0 0-10-10a104.84 104.84 0 0 0-52.91 37A104 104 0 0 0 136 224a103.1 103.1 0 0 0 62.52-20.88a104.84 104.84 0 0 0 37-52.91a8 8 0 0 0-1.98-7.98m-44.64 48.11A88 88 0 0 1 65.66 67.11a89 89 0 0 1 31.4-26A106 106 0 0 0 96 56a104.11 104.11 0 0 0 104 104a106 106 0 0 0 14.92-1.06a89 89 0 0 1-26.02 31.4"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-moon" /></svg>
<span>{HINT_TEXT}</span>
</p>
<div className={styles.floor}>
<div className={styles.char} role="img" aria-label={CHAR_LABEL}>
<span className={styles.body}>
<i className={`${styles.eye} ${styles.eyeL}`} />
<i className={`${styles.eye} ${styles.eyeR}`} />
<i className={`${styles.cheek} ${styles.cheekL}`} />
<i className={`${styles.cheek} ${styles.cheekR}`} />
<i className={styles.mouth} />
</span>
<span className={styles.zLayer} aria-hidden="true">
{[0, 1, 2].map((i) => (
<i key={i} className={`${styles.z} ${zCls[i]}`} style={{ "--i": String(i) } as ZVars}>Z</i>
))}
</span>
<span className={styles.shadow} />
</div>
</div>
<p className={styles.stateRow}>
<i className={styles.dot} aria-hidden="true" />
<span className={styles.stateText} role="status" aria-live="polite">{asleep ? ASLEEP_TEXT : AWAKE_TEXT}</span>
</p>
</div>
</main>
);
}09 放っておくと眠る待機 — 計測 累積面積 2.336% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · css キャラクター アニメーション 9選 — 挨拶から居眠りまで
このデモが属する記事
css キャラクター アニメーション 9選 — 挨拶から居眠りまでcss キャラクター アニメーションは、画面に立つマスコットの体・腕・目を transform だけで動かし、待っている・失敗した・終わったという状態を絵のほうで伝える仕組みです。ここに並べた9つはライブラリもウェブフォントも使わず、ind この記事のデモをすべて見る


