05비상 뱅 버튼
붉은 버튼을 누르면 원근 장면 안으로 깊이 가라앉으며 받침 그림자가 좁아지고 경고등이 켜집니다. 눌림은 aria-pressed 로 전달됩니다.
쓰임긴급 정지 컨트롤 목업, 이벤트 페이지의 리셋 버튼
aria-pressedperspectivetranslateZbox-shadow

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-warning" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 91.55v72.9a8 8 0 0 1-2.34 5.66l-51.55 51.55a8 8 0 0 1-5.66 2.34h-72.9a8 8 0 0 1-5.66-2.34l-51.55-51.55a8 8 0 0 1-2.34-5.66v-72.9a8 8 0 0 1 2.34-5.66l51.55-51.55A8 8 0 0 1 91.55 32h72.9a8 8 0 0 1 5.66 2.34l51.55 51.55a8 8 0 0 1 2.34 5.66" opacity=".2"/><path d="M120 136V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m112-44.45v72.9a15.86 15.86 0 0 1-4.69 11.31l-51.55 51.55a15.86 15.86 0 0 1-11.31 4.69h-72.9a15.86 15.86 0 0 1-11.31-4.69l-51.55-51.55A15.86 15.86 0 0 1 24 164.45v-72.9a15.86 15.86 0 0 1 4.69-11.31l51.55-51.55A15.86 15.86 0 0 1 91.55 24h72.9a15.86 15.86 0 0 1 11.31 4.69l51.55 51.55A15.86 15.86 0 0 1 232 91.55m-16 0L164.45 40h-72.9L40 91.55v72.9L91.55 216h72.9L216 164.45ZM128 160a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="bb is-demo" id="bb">
<p class="bb__hint">
<svg class="bb__ico" aria-hidden="true"><use href="#ico-warning"/></svg>
<span>눌러 보세요</span>
</p>
<div class="bb__scene">
<div class="bb__panel">
<span class="bb__lampWrap">
<i class="bb__lamp"><i class="bb__lampGlow"></i></i>
<span class="bb__lampText">경고</span>
</span>
<div class="bb__well">
<i class="bb__shadow"></i>
<button class="bb__btn" id="btn" type="button" aria-pressed="false" aria-label="비상 정지 버튼">
<i class="bb__dome"></i>
</button>
</div>
<p class="bb__state">
<span class="bb__stateIdle">대기</span>
<span class="bb__stateHit">가동</span>
</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 비상 뱅 버튼 — 붉은 버튼을 누르면 원근 장면(perspective) 안으로 깊이 가라앉는다(translateZ).
// 가라앉으며 받침 그림자가 좁아지고(scaleX) 경고등이 켠다. 눌림은 색이 아니라 글자(대기/가동)와
// aria-pressed 로도 전달된다. 정적 경로는 transition 으로, 격자 미리보기(.is-demo)만 루프 키프레임.
// 사람이 버튼을 누르면 JS 가 .is-demo 를 뗀다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $error;
@include stage($stage-ink);
.bb { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; max-width: 340px; }
.bb__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-cream;
}
.bb__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue-light; }
.bb__scene { display: flex; justify-content: center; }
.bb__panel {
display: flex; flex-direction: column; align-items: center; gap: $sp-1;
border-radius: $r-card; padding: $sp-2 $sp-4 $sp-3;
background: linear-gradient(180deg, rgba($stage-paper, .5), rgba($stage-paper, 0) 30%), $subject-cream;
box-shadow: $shadow-raised, inset 0 1px 0 rgba(255, 255, 255, .8), inset 0 -3px 0 rgba($subject-ink, .16);
}
.bb__lampWrap { display: inline-flex; align-items: center; gap: $sp-1; }
.bb__lamp {
position: relative; width: 14px; height: 14px; border-radius: 50%;
background: $error-soft; box-shadow: inset 0 2px 3px rgba($subject-ink, .4), inset 0 -1px 0 rgba(255, 255, 255, .7);
transition: background $duration $easing, box-shadow $duration $easing;
}
.bb__lampGlow {
position: absolute; inset: -4px; border-radius: 50%;
background: radial-gradient(circle, rgba($color, .85), rgba($color, 0) 70%);
opacity: 0;
}
.bb__lampText {
font-weight: 800; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.6; color: $subject-ink;
}
.bb.is-on .bb__lamp {
background: $color;
box-shadow: 0 0 0 2px rgba($color, .28), 0 0 14px 2px rgba($color, .65), inset 0 2px 3px rgba($subject-ink, .35);
}
// 우물 — 버튼이 들어앉은 받침. perspective 는 이 상자에 걸고 버튼은 직계 자식이어야 원근이 먹는다
.bb__well { position: relative; display: flex; justify-content: center; perspective: 600px; }
.bb__shadow {
position: absolute; left: 50%; bottom: -8px; width: 92%; height: 14px;
transform: translateX(-50%) scaleX(1); border-radius: 50%;
background: rgba($subject-ink, .34);
transition: transform $duration $easing, opacity $duration $easing;
}
.bb__btn {
position: relative; display: block; width: clamp(88px, 24vw, 108px); height: clamp(64px, 17vw, 80px);
border: 0; border-radius: 50%; padding: 0; cursor: pointer; touch-action: manipulation;
background:
radial-gradient(120% 90% at 32% 22%, rgba(255, 255, 255, .5), rgba(255, 255, 255, 0) 46%),
radial-gradient(140% 120% at 50% 118%, rgba($subject-ink, .5), rgba($subject-ink, 0) 52%),
$color;
box-shadow: 0 10px 0 rgba($subject-ink, .3), 0 14px 18px rgba($subject-ink, .35), inset 0 2px 3px rgba(255, 255, 255, .55), inset 0 -4px 6px rgba($subject-ink, .45);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.bb__btn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
.bb__dome {
position: absolute; left: 50%; top: 14%; width: 58%; height: 58%;
transform: translateX(-50%); border-radius: 50%;
background: radial-gradient(100% 100% at 34% 26%, rgba(255, 255, 255, .65), rgba(255, 255, 255, 0) 55%);
}
// 눌림 — 장면 깊이로 가라앉는다. 받침 그림자는 같은 타이밍에 좁아진다
.bb__btn[aria-pressed="true"] { transform: translateZ(-120px); }
.bb.is-on .bb__shadow { transform: translateX(-50%) scaleX(.55); opacity: .55; }
.bb__state { margin: $sp-2 0 0; font-weight: 800; font-size: clamp(11px, 3vw, 13px); line-height: 1.6; }
.bb__stateIdle { color: $subject-ink; }
.bb__stateHit { color: $error; }
.bb:not(.is-on) .bb__stateHit { display: none; }
.bb.is-on .bb__stateIdle { display: none; }
// ── 미리보기 루프(2초 한 바퀴) ─────────────────────────────────────
// 가라앉기(6% 부터) → 점등 hold 와 경고등 깜빡임 → 46% 부터 돌아온다(복귀는 90% 안에).
// 상태 글자 교체는 steps(1,end) 로 한 프레임에 컷 — 반투명 유령 프레임을 만들지 않는다.
.bb.is-demo .bb__btn {
animation-name: bbSink; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.bb.is-demo .bb__shadow {
animation-name: bbShadow; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.bb.is-demo .bb__lampGlow {
animation-name: bbBlink; animation-duration: $dur-loop;
animation-timing-function: linear; animation-iteration-count: infinite;
}
.bb.is-demo .bb__stateIdle, .bb.is-demo .bb__stateHit { display: inline; }
.bb.is-demo .bb__stateIdle {
animation-name: bbIdleCut; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.bb.is-demo .bb__stateHit {
animation-name: bbHitCut; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes bbSink {
0%, 6% { transform: translateZ(0); }
14%, 46% { transform: translateZ(-120px); }
54%, 100% { transform: translateZ(0); }
}
@keyframes bbShadow {
0%, 6% { transform: translateX(-50%) scaleX(1); opacity: 1; }
14%, 46% { transform: translateX(-50%) scaleX(.55); opacity: .55; }
54%, 100% { transform: translateX(-50%) scaleX(1); opacity: 1; }
}
@keyframes bbBlink {
0%, 9% { opacity: 0; }
12% { opacity: .45; }
18% { opacity: 1; }
24% { opacity: .5; }
30% { opacity: 1; }
36% { opacity: .5; }
42% { opacity: 1; }
47%, 100% { opacity: 0; }
}
@keyframes bbIdleCut {
0%, 13% { opacity: 1; }
14%, 46% { opacity: 0; }
47%, 100% { opacity: 1; }
}
@keyframes bbHitCut {
0%, 13% { opacity: 0; }
14%, 46% { opacity: 1; }
47%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
.bb { gap: $sp-2; }
.bb__panel { padding: $sp-2 $sp-3; gap: 0; }
.bb__shadow { bottom: -4px; height: 10px; }
}
// reduce 에서는 루프를 끄고 버튼이 올라온 정적 한 장으로 둔다
@media (prefers-reduced-motion: reduce) {
.bb.is-demo .bb__btn { animation: none !important; }
.bb.is-demo .bb__shadow { animation: none !important; }
.bb.is-demo .bb__lampGlow { animation: none !important; opacity: 0 !important; }
.bb.is-demo .bb__stateIdle { animation: none !important; }
.bb.is-demo .bb__stateHit { animation: none !important; }
.bb__btn { transition: none !important; }
.bb__shadow { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bb {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 340px;
}
.bb__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #fff7e6;
}
.bb__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #5b8cf8;
}
.bb__scene {
display: flex;
justify-content: center;
}
.bb__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border-radius: 16px;
padding: 8px 16px 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0) 30%), #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.8), inset 0 -3px 0 rgba(23, 20, 26, 0.16);
}
.bb__lampWrap {
display: inline-flex;
align-items: center;
gap: 4px;
}
.bb__lamp {
position: relative;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ffe0d8;
box-shadow: inset 0 2px 3px rgba(23, 20, 26, 0.4), inset 0 -1px 0 rgba(255, 255, 255, 0.7);
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bb__lampGlow {
position: absolute;
inset: -4px;
border-radius: 50%;
background: radial-gradient(circle, rgba(194, 54, 26, 0.85), rgba(194, 54, 26, 0) 70%);
opacity: 0;
}
.bb__lampText {
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.6;
color: #17141a;
}
.bb.is-on .bb__lamp {
background: #c2361a;
box-shadow: 0 0 0 2px rgba(194, 54, 26, 0.28), 0 0 14px 2px rgba(194, 54, 26, 0.65), inset 0 2px 3px rgba(23, 20, 26, 0.35);
}
.bb__well {
position: relative;
display: flex;
justify-content: center;
perspective: 600px;
}
.bb__shadow {
position: absolute;
left: 50%;
bottom: -8px;
width: 92%;
height: 14px;
transform: translateX(-50%) scaleX(1);
border-radius: 50%;
background: rgba(23, 20, 26, 0.34);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bb__btn {
position: relative;
display: block;
width: clamp(88px, 24vw, 108px);
height: clamp(64px, 17vw, 80px);
border: 0;
border-radius: 50%;
padding: 0;
cursor: pointer;
touch-action: manipulation;
background: radial-gradient(120% 90% at 32% 22%, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0) 46%), radial-gradient(140% 120% at 50% 118%, rgba(23, 20, 26, 0.5), rgba(23, 20, 26, 0) 52%), #c2361a;
box-shadow: 0 10px 0 rgba(23, 20, 26, 0.3), 0 14px 18px rgba(23, 20, 26, 0.35), inset 0 2px 3px rgba(255, 255, 255, 0.55), inset 0 -4px 6px rgba(23, 20, 26, 0.45);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bb__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.bb__dome {
position: absolute;
left: 50%;
top: 14%;
width: 58%;
height: 58%;
transform: translateX(-50%);
border-radius: 50%;
background: radial-gradient(100% 100% at 34% 26%, rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0) 55%);
}
.bb__btn[aria-pressed=true] {
transform: translateZ(-120px);
}
.bb.is-on .bb__shadow {
transform: translateX(-50%) scaleX(0.55);
opacity: 0.55;
}
.bb__state {
margin: 8px 0 0;
font-weight: 800;
font-size: clamp(11px, 3vw, 13px);
line-height: 1.6;
}
.bb__stateIdle {
color: #17141a;
}
.bb__stateHit {
color: #c2361a;
}
.bb:not(.is-on) .bb__stateHit {
display: none;
}
.bb.is-on .bb__stateIdle {
display: none;
}
.bb.is-demo .bb__btn {
animation-name: bbSink;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.bb.is-demo .bb__shadow {
animation-name: bbShadow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.bb.is-demo .bb__lampGlow {
animation-name: bbBlink;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.bb.is-demo .bb__stateIdle, .bb.is-demo .bb__stateHit {
display: inline;
}
.bb.is-demo .bb__stateIdle {
animation-name: bbIdleCut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.bb.is-demo .bb__stateHit {
animation-name: bbHitCut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes bbSink {
0%, 6% {
transform: translateZ(0);
}
14%, 46% {
transform: translateZ(-120px);
}
54%, 100% {
transform: translateZ(0);
}
}
@keyframes bbShadow {
0%, 6% {
transform: translateX(-50%) scaleX(1);
opacity: 1;
}
14%, 46% {
transform: translateX(-50%) scaleX(0.55);
opacity: 0.55;
}
54%, 100% {
transform: translateX(-50%) scaleX(1);
opacity: 1;
}
}
@keyframes bbBlink {
0%, 9% {
opacity: 0;
}
12% {
opacity: 0.45;
}
18% {
opacity: 1;
}
24% {
opacity: 0.5;
}
30% {
opacity: 1;
}
36% {
opacity: 0.5;
}
42% {
opacity: 1;
}
47%, 100% {
opacity: 0;
}
}
@keyframes bbIdleCut {
0%, 13% {
opacity: 1;
}
14%, 46% {
opacity: 0;
}
47%, 100% {
opacity: 1;
}
}
@keyframes bbHitCut {
0%, 13% {
opacity: 0;
}
14%, 46% {
opacity: 1;
}
47%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.bb {
gap: 8px;
}
.bb__panel {
padding: 8px 12px;
gap: 0;
}
.bb__shadow {
bottom: -4px;
height: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bb.is-demo .bb__btn {
animation: none !important;
}
.bb.is-demo .bb__shadow {
animation: none !important;
}
.bb.is-demo .bb__lampGlow {
animation: none !important;
opacity: 0 !important;
}
.bb.is-demo .bb__stateIdle {
animation: none !important;
}
.bb.is-demo .bb__stateHit {
animation: none !important;
}
.bb__btn {
transition: none !important;
}
.bb__shadow {
transition: none !important;
}
}(function () {
var root = document.getElementById('bb');
var btn = document.getElementById('btn');
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', on ? 'false' : 'true');
root.classList.toggle('is-on', !on);
});
})();// BangButton — vanilla 판(run/013/demo/05-skeuo-bang-button) 의 React 포트.
// 누르면 원근 장면 안으로 translateZ 깊이 가라앉고 받침 그림자가 좁아지며 경고등이 켠다.
// 눌림은 aria-pressed 와 글자(대기/가동) 둘로 전달된다. 값·타이밍은 vanilla 와 같다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BangButton.module.scss";
export type BangButtonProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HINT = "눌러 보세요";
const ARIA_LABEL = "비상 정지 버튼";
const LAMP_TEXT = "경고";
const STATE_IDLE = "대기";
const STATE_HIT = "가동";
export default function BangButton({
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#c2361a",
}: BangButtonProps) {
const [on, setOn] = useState(false);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function press() {
setDemo(false);
setOn((v) => !v);
}
const cls = [styles.root, demo ? styles.isDemo : "", on ? styles.isOn : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-warning" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 91.55v72.9a8 8 0 0 1-2.34 5.66l-51.55 51.55a8 8 0 0 1-5.66 2.34h-72.9a8 8 0 0 1-5.66-2.34l-51.55-51.55a8 8 0 0 1-2.34-5.66v-72.9a8 8 0 0 1 2.34-5.66l51.55-51.55A8 8 0 0 1 91.55 32h72.9a8 8 0 0 1 5.66 2.34l51.55 51.55a8 8 0 0 1 2.34 5.66" opacity=".2"/><path d="M120 136V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m112-44.45v72.9a15.86 15.86 0 0 1-4.69 11.31l-51.55 51.55a15.86 15.86 0 0 1-11.31 4.69h-72.9a15.86 15.86 0 0 1-11.31-4.69l-51.55-51.55A15.86 15.86 0 0 1 24 164.45v-72.9a15.86 15.86 0 0 1 4.69-11.31l51.55-51.55A15.86 15.86 0 0 1 91.55 24h72.9a15.86 15.86 0 0 1 11.31 4.69l51.55 51.55A15.86 15.86 0 0 1 232 91.55m-16 0L164.45 40h-72.9L40 91.55v72.9L91.55 216h72.9L216 164.45ZM128 160a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-warning" /></svg>
<span>{HINT}</span>
</p>
<div className={styles.scene}>
<div className={styles.panel}>
<span className={styles.lampWrap}>
<i className={styles.lamp}><i className={styles.lampGlow} /></i>
<span className={styles.lampText}>{LAMP_TEXT}</span>
</span>
<div className={styles.well}>
<i className={styles.shadow} />
<button className={styles.btn} type="button" aria-pressed={on} aria-label={ARIA_LABEL} onClick={press}>
<i className={styles.dome} />
</button>
</div>
<p className={styles.state}>
<span className={styles.stateIdle}>{STATE_IDLE}</span>
<span className={styles.stateHit}>{STATE_HIT}</span>
</p>
</div>
</div>
</div>
</main>
);
}05 비상 뱅 버튼 — 측정 누적면적 4.902% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 스위치 버튼 9종 — 실물처럼 눌리는 레버·다이얼·이젝트
이 데모가 속한 글
CSS 스위치 버튼 9종 — 실물처럼 눌리는 레버·다이얼·이젝트css 스위치 버튼은 화면에서 켜고 끄는 스위치를 실물 레버·로커·다이얼처럼 보이게 만든 버튼입니다. 여기 아홉 개는 라이브러리 없이 SCSS 와 짧은 JS 로만 짰고, 누르면 그 자리에서 기울고 돌고 튀어나오며 화 이 글의 데모 전부 보기

