9 Icon Button CSS Patterns — Press, Drag, Expand
An icon button css pattern trades the label for a picture, so the meaning has to survive for anyone who never sees it.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Speed dial that turns 45 degrees to hand out actions
- 02 Toolbar FABs that read which way you scroll
- 03 Icon ripple spreading from the exact press point
- 04 Favorite star that draws its line, then fills
- 05 Theme button where the sun and moon trade places
- 06 Close button with a ring that keeps circling
- 07 Search FAB that unfolds into an input
- 08 Help button that opens a chat launcher
- 09 FAB that follows your long-press drag
The order runs from the widest job a button can hold, down to the smallest. First come the two that own the whole screen: one press splits into three actions (01), and a row of tools steps aside while you read (02). The next four differ only in how they answer a press — a wave starts at the pixel you touched (03), a star traces its outline before it fills (04), a sun and a moon swap seats (05), and a close mark spins a ring under the pointer (06). The last three change the button itself: an icon widens into a text field (07), a launcher drags a chat panel up with it (08), and a long press hands you the button to carry somewhere else (09). Where an icon button animation css trick ends and a script begins is exactly here, since anything that moves or reads the scroll needs someone to hold the value. What unfolds after the press is the axis button state css patterns dig into, and drawing the wait itself is the job of the AI chat loading animations.
01Speed dial that turns 45 degrees to hand out actions
What you see
One create button cannot hold writing, photos, and voice at the same time unless it splits after the press. The plus mark turns 45 degrees into a close mark while three labeled buttons rise 40ms apart, and whether it is open or shut is written to aria-expanded rather than guessed from a class name.
<main class="stage">
<section class="sd is-demo" id="sd" aria-label="Create tools speed dial">
<p class="sd__hint" id="sd-hint"><span class="sd__hint-a">What will you make?</span><span class="sd__hint-b">Pick an action</span></p>
<ul class="sd__menu" id="sd-menu">
<li class="sd__item">
<button class="sd__sub" type="button" aria-label="Write">
<span class="sd__tag">Write</span>
<span class="sd__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M221.66,90.34,192,120,136,64l29.66-29.66a8,8,0,0,1,11.31,0L221.66,79A8,8,0,0,1,221.66,90.34Z" opacity=".2"/><path d="M227.32,73.37,182.63,28.69a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31l83.67-83.66,3.48,13.9-36.8,36.79a8,8,0,0,0,11.31,11.32l40-40a8,8,0,0,0,2.11-7.6l-6.9-27.61L227.32,96A16,16,0,0,0,227.32,73.37ZM48,208V179.31L76.69,208Zm48-3.31L51.31,160,136,75.31,180.69,120Zm96-96L147.32,64l24-24L216,84.69Z"/></svg></span>
</button>
</li>
<li class="sd__item">
<button class="sd__sub" type="button" aria-label="Upload a photo">
<span class="sd__tag">Upload a photo</span>
<span class="sd__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M224,56V178.06l-39.72-39.72a8,8,0,0,0-11.31,0L147.31,164,97.66,114.34a8,8,0,0,0-11.32,0L32,168.69V56a8,8,0,0,1,8-8H216A8,8,0,0,1,224,56Z" opacity=".2"/><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,16V158.75l-26.07-26.06a16,16,0,0,0-22.63,0l-20,20-44-44a16,16,0,0,0-22.62,0L40,149.37V56ZM40,172l52-52,80,80H40Zm176,28H194.63l-36-36,20-20L216,181.38V200ZM144,100a12,12,0,1,1,12,12A12,12,0,0,1,144,100Z"/></svg></span>
</button>
</li>
<li class="sd__item">
<button class="sd__sub" type="button" aria-label="Record voice">
<span class="sd__tag">Record voice</span>
<span class="sd__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M168,64v64a40,40,0,0,1-40,40h0a40,40,0,0,1-40-40V64a40,40,0,0,1,40-40h0A40,40,0,0,1,168,64Z" opacity=".2"/><path d="M128,176a48.05,48.05,0,0,0,48-48V64a48,48,0,0,0-96,0v64A48.05,48.05,0,0,0,128,176ZM96,64a32,32,0,0,1,64,0v64a32,32,0,0,1-64,0Zm40,143.6V240a8,8,0,0,1-16,0V207.6A80.11,80.11,0,0,1,48,128a8,8,0,0,1,16,0,64,64,0,0,0,128,0,8,8,0,0,1,16,0A80.11,80.11,0,0,1,136,207.6Z"/></svg></span>
</button>
</li>
</ul>
<div class="sd__dock">
<button class="sd__fab" type="button" id="sd-fab" aria-expanded="false" aria-controls="sd-menu" aria-label="Open the create menu">
<span class="sd__plus" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity=".2"/><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></svg></span>
</button>
</div>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
// 스피드 다이얼 FAB — 더하기가 45도 돌아 닫기 표시가 되고 보조 버튼이 시차로 쌓인다
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
$line: rgba(255, 247, 230, .18);
$soft: color.mix($subject-cream, $stage-ink, 10%);
.sd {
position: relative;
box-sizing: border-box;
width: min(236px, 92vw);
display: grid;
justify-items: end;
gap: $sp-1;
}
.sd__hint {
display: inline-grid;
margin: 0;
color: rgba(255, 247, 230, .62);
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
// 두 문구를 겹쳐 두고 컷으로 갈아 끼운다 — 반투명 겹침이 생기지 않는다
.sd__hint-a,
.sd__hint-b { grid-area: 1 / 1; }
.sd__hint-b { opacity: 0; color: $color; }
.sd.is-open .sd__hint-a { opacity: 0; }
.sd.is-open .sd__hint-b { opacity: 1; }
// ── 보조 동작 스택: 자리는 항상 확보(절대배치 넘침 방지), 보임만 변한다 ──
.sd__menu {
display: grid;
justify-items: end;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
.sd__item { display: grid; }
.sd__sub {
display: inline-flex;
align-items: center;
gap: $sp-2;
min-width: 168px;
margin: 0;
padding: 0 $sp-2 0 $sp-3;
border: 1px solid $line;
border-radius: $r-pill;
background: $soft;
color: $subject-cream;
font-family: inherit;
font-size: 13px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transform: translateY(12px) scale(.6);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $dur-instant linear;
}
.sd__sub:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sd__tag { order: 0; }
.sd__ic {
order: 1;
display: inline-flex;
width: 24px;
height: 24px;
padding: $sp-1;
border-radius: 50%;
background: rgba(255, 247, 230, .12);
}
// 정적(손으로 조작) 경로 — .is-demo 가 없어도 열린 모습이 그대로 보인다
.sd.is-open .sd__sub {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
.sd__item:nth-child(1) .sd__sub { transition-delay: 0ms; }
.sd__item:nth-child(2) .sd__sub { transition-delay: 40ms; }
.sd__item:nth-child(3) .sd__sub { transition-delay: 80ms; }
// ── FAB: 더하기 아이콘이 rotate(45deg) 로 닫기 표시가 된다 ──
.sd__dock { display: grid; }
.sd__fab {
display: inline-grid;
place-items: center;
width: 52px;
height: 52px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: $color;
color: $subject-cream;
font-size: 26px;
line-height: 1;
cursor: pointer;
box-shadow: $shadow-raised;
}
.sd__fab:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.sd__plus {
display: inline-flex;
transition: transform $dur-base $ease-spring;
}
.sd.is-open .sd__plus { transform: rotate(45deg); }
.sd__fab:active .sd__plus { transform: rotate(45deg) scale(.92); }
// ── 격자 미리보기 전용 2초 루프 ──
// 등장: 보임은 컷으로 자르고(반투명 유령 금지) 자리 잡기는 transform 이 채운다
// 퇴장은 한 장씩 시각을 벌리고 크기만 줄여(면적 작게) 등장 쪽이 최대 변화가 되게 한다
@keyframes sd-in-1 {
0%, 11% { transform: translateY(12px) scale(.6); opacity: 0; }
12% { opacity: 1; }
12.01%, 24% { transform: translateY(0) scale(1); opacity: 1; }
72% { transform: translateY(0) scale(1); opacity: 1; }
78% { transform: translateY(8px) scale(.6); opacity: 0; }
78.01%, 100% { transform: translateY(12px) scale(.6); opacity: 0; }
}
@keyframes sd-in-2 {
0%, 19% { transform: translateY(12px) scale(.6); opacity: 0; }
20% { opacity: 1; }
20.01%, 32% { transform: translateY(0) scale(1); opacity: 1; }
78% { transform: translateY(0) scale(1); opacity: 1; }
84% { transform: translateY(8px) scale(.6); opacity: 0; }
84.01%, 100% { transform: translateY(12px) scale(.6); opacity: 0; }
}
@keyframes sd-in-3 {
0%, 27% { transform: translateY(12px) scale(.6); opacity: 0; }
28% { opacity: 1; }
28.01%, 40% { transform: translateY(0) scale(1); opacity: 1; }
84% { transform: translateY(0) scale(1); opacity: 1; }
90% { transform: translateY(8px) scale(.6); opacity: 0; }
90.01%, 100% { transform: translateY(12px) scale(.6); opacity: 0; }
}
@keyframes sd-turn {
0%, 10% { transform: rotate(0deg); }
24% { transform: rotate(45deg); }
84% { transform: rotate(45deg); }
94%, 100% { transform: rotate(0deg); }
}
@keyframes sd-press {
0%, 8% { transform: scale(1); }
12% { transform: scale(.92); }
18%, 100% { transform: scale(1); }
}
@keyframes sd-hint-a {
0%, 27% { opacity: 1; }
28%, 89% { opacity: 0; }
90%, 100% { opacity: 1; }
}
@keyframes sd-hint-b {
0%, 27% { opacity: 0; }
28%, 89% { opacity: 1; }
90%, 100% { opacity: 0; }
}
.sd.is-demo {
.sd__item:nth-child(1) .sd__sub { animation-name: sd-in-1; }
.sd__item:nth-child(2) .sd__sub { animation-name: sd-in-2; }
.sd__item:nth-child(3) .sd__sub { animation-name: sd-in-3; }
.sd__sub {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sd__plus {
animation-name: sd-turn;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.sd__fab {
animation-name: sd-press;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.sd__hint-a {
animation-name: sd-hint-a;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sd__hint-b {
animation-name: sd-hint-b;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.sd { gap: $sp-1; }
.sd__hint { font-size: 11px; }
.sd__menu { gap: $sp-1; }
.sd__sub { min-width: 140px; font-size: 11px; }
.sd__ic { width: 18px; height: 18px; }
.sd__fab { width: 44px; height: 44px; font-size: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.sd.is-demo .sd__sub,
.sd.is-demo .sd__plus,
.sd.is-demo .sd__fab,
.sd.is-demo .sd__hint-a,
.sd.is-demo .sd__hint-b { animation: none !important; transform: none !important; }
// 움직임만 끄고 열린 결과는 남긴다
.sd.is-demo .sd__sub { opacity: 1 !important; pointer-events: auto !important; }
.sd.is-demo .sd__plus { transform: rotate(45deg) !important; }
.sd.is-demo .sd__hint-a { opacity: 0 !important; }
.sd.is-demo .sd__hint-b { opacity: 1 !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);
}
.sd {
position: relative;
box-sizing: border-box;
width: min(236px, 92vw);
display: grid;
justify-items: end;
gap: 4px;
}
.sd__hint {
display: inline-grid;
margin: 0;
color: rgba(255, 247, 230, 0.62);
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
.sd__hint-a,
.sd__hint-b {
grid-area: 1/1;
}
.sd__hint-b {
opacity: 0;
color: #2f6df6;
}
.sd.is-open .sd__hint-a {
opacity: 0;
}
.sd.is-open .sd__hint-b {
opacity: 1;
}
.sd__menu {
display: grid;
justify-items: end;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.sd__item {
display: grid;
}
.sd__sub {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 168px;
margin: 0;
padding: 0 8px 0 12px;
border: 1px solid rgba(255, 247, 230, 0.18);
border-radius: 999px;
background: rgb(18.1176470588%, 16.7450980392%, 18.1960784314%);
color: #fff7e6;
font-family: inherit;
font-size: 13px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transform: translateY(12px) scale(0.6);
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 140ms linear;
}
.sd__sub:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sd__tag {
order: 0;
}
.sd__ic {
order: 1;
display: inline-flex;
width: 24px;
height: 24px;
padding: 4px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.12);
}
.sd.is-open .sd__sub {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
.sd__item:nth-child(1) .sd__sub {
transition-delay: 0ms;
}
.sd__item:nth-child(2) .sd__sub {
transition-delay: 40ms;
}
.sd__item:nth-child(3) .sd__sub {
transition-delay: 80ms;
}
.sd__dock {
display: grid;
}
.sd__fab {
display: inline-grid;
place-items: center;
width: 52px;
height: 52px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: #2f6df6;
color: #fff7e6;
font-size: 26px;
line-height: 1;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sd__fab:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.sd__plus {
display: inline-flex;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sd.is-open .sd__plus {
transform: rotate(45deg);
}
.sd__fab:active .sd__plus {
transform: rotate(45deg) scale(0.92);
}
@keyframes sd-in-1 {
0%, 11% {
transform: translateY(12px) scale(0.6);
opacity: 0;
}
12% {
opacity: 1;
}
12.01%, 24% {
transform: translateY(0) scale(1);
opacity: 1;
}
72% {
transform: translateY(0) scale(1);
opacity: 1;
}
78% {
transform: translateY(8px) scale(0.6);
opacity: 0;
}
78.01%, 100% {
transform: translateY(12px) scale(0.6);
opacity: 0;
}
}
@keyframes sd-in-2 {
0%, 19% {
transform: translateY(12px) scale(0.6);
opacity: 0;
}
20% {
opacity: 1;
}
20.01%, 32% {
transform: translateY(0) scale(1);
opacity: 1;
}
78% {
transform: translateY(0) scale(1);
opacity: 1;
}
84% {
transform: translateY(8px) scale(0.6);
opacity: 0;
}
84.01%, 100% {
transform: translateY(12px) scale(0.6);
opacity: 0;
}
}
@keyframes sd-in-3 {
0%, 27% {
transform: translateY(12px) scale(0.6);
opacity: 0;
}
28% {
opacity: 1;
}
28.01%, 40% {
transform: translateY(0) scale(1);
opacity: 1;
}
84% {
transform: translateY(0) scale(1);
opacity: 1;
}
90% {
transform: translateY(8px) scale(0.6);
opacity: 0;
}
90.01%, 100% {
transform: translateY(12px) scale(0.6);
opacity: 0;
}
}
@keyframes sd-turn {
0%, 10% {
transform: rotate(0deg);
}
24% {
transform: rotate(45deg);
}
84% {
transform: rotate(45deg);
}
94%, 100% {
transform: rotate(0deg);
}
}
@keyframes sd-press {
0%, 8% {
transform: scale(1);
}
12% {
transform: scale(0.92);
}
18%, 100% {
transform: scale(1);
}
}
@keyframes sd-hint-a {
0%, 27% {
opacity: 1;
}
28%, 89% {
opacity: 0;
}
90%, 100% {
opacity: 1;
}
}
@keyframes sd-hint-b {
0%, 27% {
opacity: 0;
}
28%, 89% {
opacity: 1;
}
90%, 100% {
opacity: 0;
}
}
.sd.is-demo .sd__item:nth-child(1) .sd__sub {
animation-name: sd-in-1;
}
.sd.is-demo .sd__item:nth-child(2) .sd__sub {
animation-name: sd-in-2;
}
.sd.is-demo .sd__item:nth-child(3) .sd__sub {
animation-name: sd-in-3;
}
.sd.is-demo .sd__sub {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sd.is-demo .sd__plus {
animation-name: sd-turn;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.sd.is-demo .sd__fab {
animation-name: sd-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.sd.is-demo .sd__hint-a {
animation-name: sd-hint-a;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sd.is-demo .sd__hint-b {
animation-name: sd-hint-b;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.sd {
gap: 4px;
}
.sd__hint {
font-size: 11px;
}
.sd__menu {
gap: 4px;
}
.sd__sub {
min-width: 140px;
font-size: 11px;
}
.sd__ic {
width: 18px;
height: 18px;
}
.sd__fab {
width: 44px;
height: 44px;
font-size: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.sd.is-demo .sd__sub,
.sd.is-demo .sd__plus,
.sd.is-demo .sd__fab,
.sd.is-demo .sd__hint-a,
.sd.is-demo .sd__hint-b {
animation: none !important;
transform: none !important;
}
.sd.is-demo .sd__sub {
opacity: 1 !important;
pointer-events: auto !important;
}
.sd.is-demo .sd__plus {
transform: rotate(45deg) !important;
}
.sd.is-demo .sd__hint-a {
opacity: 0 !important;
}
.sd.is-demo .sd__hint-b {
opacity: 1 !important;
}
}// 스피드 다이얼 FAB: 열림은 aria-expanded 에 기록하고, 닫기는 Escape 로도 된다.
var sd = document.getElementById('sd');
var sdFab = document.getElementById('sd-fab');
var sdMenu = document.getElementById('sd-menu');
function sdOpen(v) {
sd.classList.remove('is-demo');
sd.classList.toggle('is-open', v);
sdFab.setAttribute('aria-expanded', v ? 'true' : 'false');
sdFab.setAttribute('aria-label', v ? 'Close the create menu' : 'Open the create menu');
}
sdFab.addEventListener('click', function () {
sdOpen(!sd.classList.contains('is-open'));
});
sdMenu.querySelectorAll('.sd__sub').forEach(function (b) {
b.addEventListener('click', function () { sdOpen(false); });
});
sd.addEventListener('keydown', function (e) {
if (e.key === 'Escape') sdOpen(false);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpeedDialFab.module.scss";
export type SpeedDialFabProps = {
/** 보조 동작 라벨 — 아이콘은 순서대로 글쓰기·사진·녹음 3종이 돌아간다 */
actions?: string[];
hintIdle?: string;
hintOpen?: string;
hintPicked?: string;
openLabel?: string;
closeLabel?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 KeyboardEvent 를 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type KeyEvt = { key: string };
const DEFAULT_ACTIONS = ["Write a post", "Upload a photo", "Record a voice note"];
let seq = 0;
function PencilIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M221.66,90.34,192,120,136,64l29.66-29.66a8,8,0,0,1,11.31,0L221.66,79A8,8,0,0,1,221.66,90.34Z" opacity=".2"/><path d="M227.32,73.37,182.63,28.69a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31l83.67-83.66,3.48,13.9-36.8,36.79a8,8,0,0,0,11.31,11.32l40-40a8,8,0,0,0,2.11-7.6l-6.9-27.61L227.32,96A16,16,0,0,0,227.32,73.37ZM48,208V179.31L76.69,208Zm48-3.31L51.31,160,136,75.31,180.69,120Zm96-96L147.32,64l24-24L216,84.69Z"/></svg>
);
}
function ImageIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M224,56V178.06l-39.72-39.72a8,8,0,0,0-11.31,0L147.31,164,97.66,114.34a8,8,0,0,0-11.32,0L32,168.69V56a8,8,0,0,1,8-8H216A8,8,0,0,1,224,56Z" opacity=".2"/><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,16V158.75l-26.07-26.06a16,16,0,0,0-22.63,0l-20,20-44-44a16,16,0,0,0-22.62,0L40,149.37V56ZM40,172l52-52,80,80H40Zm176,28H194.63l-36-36,20-20L216,181.38V200ZM144,100a12,12,0,1,1,12,12A12,12,0,0,1,144,100Z"/></svg>
);
}
function MicIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M168,64v64a40,40,0,0,1-40,40h0a40,40,0,0,1-40-40V64a40,40,0,0,1,40-40h0A40,40,0,0,1,168,64Z" opacity=".2"/><path d="M128,176a48.05,48.05,0,0,0,48-48V64a48,48,0,0,0-96,0v64A48.05,48.05,0,0,0,128,176ZM96,64a32,32,0,0,1,64,0v64a32,32,0,0,1-64,0Zm40,143.6V240a8,8,0,0,1-16,0V207.6A80.11,80.11,0,0,1,48,128a8,8,0,0,1,16,0,64,64,0,0,0,128,0,8,8,0,0,1,16,0A80.11,80.11,0,0,1,136,207.6Z"/></svg>
);
}
const ICONS = [PencilIcon, ImageIcon, MicIcon];
export default function SpeedDialFab({
actions = DEFAULT_ACTIONS,
hintIdle = "What do you want to make?",
hintOpen = "Pick an action",
hintPicked = "Coming soon",
openLabel = "Open create menu",
closeLabel = "Close create menu",
groupLabel = "Create tools speed dial",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: SpeedDialFabProps) {
const [open, setOpen] = useState(false);
const [live, setLive] = useState(demo);
const [hint, setHint] = useState(hintIdle);
const [menuId] = useState<string>(() => "speed-dial-menu-" + String(++seq));
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle() {
setLive(false);
const next = !open;
setOpen(next);
setHint(next ? hintOpen : hintIdle);
}
function pick() {
// 보조 동작을 고르면 다이얼은 접히고 안내만 바뀐다
setLive(false);
setOpen(false);
setHint(hintPicked);
}
function onKeyDown(e: KeyEvt) {
if (e.key !== "Escape") return;
setLive(false);
setOpen(false);
setHint(hintIdle);
}
const cls = [styles.sd, live ? styles.isDemo : "", open ? styles.isOpen : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel} onKeyDown={onKeyDown}>
<p className={styles.hint}>{hint}</p>
<ul className={styles.menu} id={menuId}>
{actions.map((label, i) => {
const Icon = ICONS[i % ICONS.length];
return (
<li className={styles.item} key={label}>
<button className={styles.sub} type="button" aria-label={label} onClick={pick}>
<span className={styles.tag}>{label}</span>
<span className={styles.ic} aria-hidden="true"><Icon /></span>
</button>
</li>
);
})}
</ul>
<div className={styles.dock}>
<button
className={styles.fab}
type="button"
aria-expanded={open}
aria-controls={menuId}
aria-label={open ? closeLabel : openLabel}
onClick={toggle}
>
<span className={styles.plus} aria-hidden="true">
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity=".2"/><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></svg>
</span>
</button>
</div>
</section>
);
}01 · Speed dial that turns 45 degrees to hand out actions — Measured area 12.706% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Escape closes it, and 9 of the 24 preview frames carry movement.
.sd__sub {
transform: translateY(12px) scale(0.6);
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1),
opacity 140ms linear;
}
.sd.is-open .sd__sub {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
.sd__item:nth-child(2) .sd__sub { transition-delay: 40ms; }
.sd__item:nth-child(3) .sd__sub { transition-delay: 80ms; }
.sd__plus { transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1); }
.sd.is-open .sd__plus { transform: rotate(45deg); }
02Toolbar FABs that read which way you scroll
How an icon button ui design hides with the scroll
Tools that cover the paragraph you are reading stop being tools. This part compares scrollTop against its last value: a rising number means you are going down, so the bar of three round buttons leaves by translateY(150%), and a falling number brings it straight back.
<main class="stage">
<section class="tb is-demo" id="tb" aria-label="Toolbar buttons that hide while you read">
<header class="tb__head">
<p class="tb__title">Today's reads</p>
<p class="tb__state" id="tb-state">Tools showing</p>
</header>
<div class="tb__view" id="tb-view" tabindex="0" role="region" aria-label="Article list">
<ul class="tb__list">
<li class="tb__row"><b>01</b> A card that holds soft shadows</li>
<li class="tb__row"><b>02</b> An input that welcomes the hand</li>
<li class="tb__row"><b>03</b> A notice one beat late</li>
<li class="tb__row"><b>04</b> A list that breathes as it folds</li>
<li class="tb__row"><b>05</b> A progress bar that fills to the brim</li>
<li class="tb__row"><b>06</b> A button that remembers the press</li>
<li class="tb__row"><b>07</b> A ruler that follows the scroll</li>
<li class="tb__row"><b>08</b> A chip that leaves the stage</li>
<li class="tb__row"><b>09</b> A ripple blooming at the fingertip</li>
</ul>
</div>
<nav class="tb__bar" id="tb-bar" aria-label="Reading tools">
<button class="tb__fab" type="button" id="tb-top" aria-label="Back to top">
<span aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M208,160H48l80-80Z" opacity=".2"/><path d="M213.66,154.34l-80-80a8,8,0,0,0-11.32,0l-80,80A8,8,0,0,0,48,168H208a8,8,0,0,0,5.66-13.66ZM67.31,152,128,91.31,188.69,152Z"/></svg></span>
</button>
<button class="tb__fab" type="button" aria-label="Find in the list">
<span aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M192,112a80,80,0,1,1-80-80A80,80,0,0,1,192,112Z" opacity=".2"/><path d="M229.66,218.34,179.6,168.28a88.21,88.21,0,1,0-11.32,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg></span>
</button>
<button class="tb__fab" type="button" aria-label="Open the index">
<span aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M216,64V192H40V64Z" opacity=".2"/><path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/></svg></span>
</button>
</nav>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스크롤 방향을 읽고 숨는 툴바 FAB — 목록을 내리면 도구가 치워지고 올리면 돌아온다
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.tb {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.tb__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3 0;
}
.tb__title {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.tb__state {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba(23, 20, 26, .72);
}
.tb__view {
box-sizing: border-box;
height: 112px;
margin: $sp-2 $sp-2 $sp-2;
padding: 0 $sp-2 0 0;
overflow-y: auto;
scrollbar-width: none;
}
.tb__view::-webkit-scrollbar { display: none; }
.tb__view:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.tb__list {
display: grid;
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
.tb__row {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
font-size: 11px;
line-height: 1.35;
}
.tb__row b { font-size: 10px; opacity: .72; }
// ── 툴바 FAB: 오른쪽 아래에 붙고, 방향에 따라 translateY 로 숨는다 ──
.tb__bar {
position: absolute;
right: $sp-2;
bottom: $sp-2;
display: flex;
gap: $sp-1;
padding: $sp-1;
border-radius: $r-pill;
background: $subject-ink;
box-shadow: $shadow-press;
transition: transform $duration $easing, opacity $dur-instant linear;
}
.tb__fab {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $stage-yellow;
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: transform $dur-instant $ease-out;
}
.tb__fab:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 1px; }
.tb__fab:active { transform: scale(.88); }
// 정적 경로 — is-hidden 일 때만 밖으로 내려간다
.tb.is-hidden .tb__bar {
transform: translateY(150%);
opacity: 0;
}
// ── 격자 미리보기 전용 2초 루프: 목록이 흐르고 도구가 숨었다 돌아온다 ──
@keyframes tb-flow {
0% { transform: translateY(0); }
34% { transform: translateY(-44%); }
44% { transform: translateY(-44%); }
82%, 100% { transform: translateY(0); }
}
@keyframes tb-hide {
0%, 30% { transform: translateY(0); opacity: 1; }
34% { opacity: 0; }
34.01%, 43% { transform: translateY(150%); opacity: 0; }
47% { opacity: 1; }
47.01%, 92% { transform: translateY(0); opacity: 1; }
94%, 100% { transform: translateY(0); opacity: 1; }
}
@keyframes tb-note {
0%, 31% { opacity: 1; }
32%, 90% { opacity: .35; }
91%, 100% { opacity: 1; }
}
.tb.is-demo {
.tb__list {
animation-name: tb-flow;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.tb__bar {
animation-name: tb-hide;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.tb__state {
animation-name: tb-note;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.tb__view { height: 96px; }
.tb__row { font-size: 10px; }
.tb__fab { width: 28px; height: 28px; font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.tb.is-demo .tb__list,
.tb.is-demo .tb__bar,
.tb.is-demo .tb__state { animation: none !important; transform: none !important; }
// 방향 감지 결과만 남는다 — 도구는 보이는 상태로 멈춘다
.tb.is-demo .tb__bar { opacity: 1 !important; }
.tb.is-demo .tb__state { opacity: 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);
}
.tb {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.tb__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 8px 12px 0;
}
.tb__title {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.tb__state {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.tb__view {
box-sizing: border-box;
height: 112px;
margin: 8px 8px 8px;
padding: 0 8px 0 0;
overflow-y: auto;
scrollbar-width: none;
}
.tb__view::-webkit-scrollbar {
display: none;
}
.tb__view:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.tb__list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.tb__row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
font-size: 11px;
line-height: 1.35;
}
.tb__row b {
font-size: 10px;
opacity: 0.72;
}
.tb__bar {
position: absolute;
right: 8px;
bottom: 8px;
display: flex;
gap: 4px;
padding: 4px;
border-radius: 999px;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 140ms linear;
}
.tb__fab {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: #ffd23f;
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: transform 140ms ease-out;
}
.tb__fab:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 1px;
}
.tb__fab:active {
transform: scale(0.88);
}
.tb.is-hidden .tb__bar {
transform: translateY(150%);
opacity: 0;
}
@keyframes tb-flow {
0% {
transform: translateY(0);
}
34% {
transform: translateY(-44%);
}
44% {
transform: translateY(-44%);
}
82%, 100% {
transform: translateY(0);
}
}
@keyframes tb-hide {
0%, 30% {
transform: translateY(0);
opacity: 1;
}
34% {
opacity: 0;
}
34.01%, 43% {
transform: translateY(150%);
opacity: 0;
}
47% {
opacity: 1;
}
47.01%, 92% {
transform: translateY(0);
opacity: 1;
}
94%, 100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes tb-note {
0%, 31% {
opacity: 1;
}
32%, 90% {
opacity: 0.35;
}
91%, 100% {
opacity: 1;
}
}
.tb.is-demo .tb__list {
animation-name: tb-flow;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.tb.is-demo .tb__bar {
animation-name: tb-hide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.tb.is-demo .tb__state {
animation-name: tb-note;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.tb__view {
height: 96px;
}
.tb__row {
font-size: 10px;
}
.tb__fab {
width: 28px;
height: 28px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.tb.is-demo .tb__list,
.tb.is-demo .tb__bar,
.tb.is-demo .tb__state {
animation: none !important;
transform: none !important;
}
.tb.is-demo .tb__bar {
opacity: 1 !important;
}
.tb.is-demo .tb__state {
opacity: 1 !important;
}
}// 스크롤 방향을 읽는 툴바 FAB: scrollTop 변화로 방향을 잡아 숨김·노출만 바꾼다
var tb = document.getElementById('tb');
var tbView = document.getElementById('tb-view');
var tbBar = document.getElementById('tb-bar');
var tbState = document.getElementById('tb-state');
var tbLast = tbView.scrollTop;
tbView.addEventListener('scroll', function () {
tb.classList.remove('is-demo');
var now = tbView.scrollTop;
var down = now - tbLast > 2;
var up = tbLast - now > 2;
tbLast = now;
if (down && !tb.classList.contains('is-hidden')) {
tb.classList.add('is-hidden');
tbState.textContent = 'Reading · tools tucked';
} else if (up && tb.classList.contains('is-hidden')) {
tb.classList.remove('is-hidden');
tbState.textContent = 'Tools showing';
}
});
document.getElementById('tb-top').addEventListener('click', function () {
tbView.scrollTo({ top: 0, behavior: 'smooth' });
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ScrollToolbarFab.module.scss";
export type ScrollToolbarFabProps = {
title?: string;
rows?: string[];
stateVisible?: string;
stateHidden?: string;
topLabel?: string;
findLabel?: string;
tocLabel?: string;
listLabel?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 DOM 요소 타입을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type ScrollBox = { scrollTop: number; scrollTo: (o: { top: number; behavior: string }) => void };
const DEFAULT_ROWS = [
"A card that holds a soft shadow",
"An input you can stroke with both hands",
"A toast that lands one beat late",
"The breath of a list folding open",
"A progress bar that fills to the end",
"A button that remembers where it was pressed",
"A scale that follows the scroll",
"A chip that leaves the stage",
"A ripple blooming under a fingertip",
];
function ArrowUpIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M208,160H48l80-80Z" opacity=".2"/><path d="M213.66,154.34l-80-80a8,8,0,0,0-11.32,0l-80,80A8,8,0,0,0,48,168H208a8,8,0,0,0,5.66-13.66ZM67.31,152,128,91.31,188.69,152Z"/></svg>
);
}
function SearchIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M192,112a80,80,0,1,1-80-80A80,80,0,0,1,192,112Z" opacity=".2"/><path d="M229.66,218.34,179.6,168.28a88.21,88.21,0,1,0-11.32,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg>
);
}
function ListIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M216,64V192H40V64Z" opacity=".2"/><path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/></svg>
);
}
export default function ScrollToolbarFab({
title = "Today's reading",
rows = DEFAULT_ROWS,
stateVisible = "Tools visible",
stateHidden = "Reading · tools hidden",
topLabel = "Back to top",
findLabel = "Find in list",
tocLabel = "Open table of contents",
listLabel = "Article list",
groupLabel = "Toolbar that hides while reading",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: ScrollToolbarFabProps) {
const [hidden, setHidden] = useState(false);
const [live, setLive] = useState(demo);
const viewRef = useRef<ScrollBox>(null);
const lastRef = useRef<number>(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 스크롤 방향만 읽는다 — 내려가면 도구를 치우고 올라가면 되돌린다
function onScroll() {
const el = viewRef.current;
if (!el) return;
setLive(false);
const now = el.scrollTop;
const last = lastRef.current === null ? 0 : lastRef.current;
lastRef.current = now;
if (now - last > 2) setHidden(true);
else if (last - now > 2) setHidden(false);
}
function toTop() {
setLive(false);
const el = viewRef.current;
if (!el) return;
el.scrollTo({ top: 0, behavior: "smooth" });
}
const cls = [styles.tb, live ? styles.isDemo : "", hidden ? styles.isHidden : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.head}>
<p className={styles.title}>{title}</p>
<p className={styles.state}>{hidden ? stateHidden : stateVisible}</p>
</header>
<div className={styles.view} ref={viewRef} onScroll={onScroll} tabIndex={0} role="region" aria-label={listLabel}>
<ul className={styles.list}>
{rows.map((r, i) => (
<li className={styles.row} key={r}>
<b>{String(i + 1).padStart(2, "0")}</b> {r}
</li>
))}
</ul>
</div>
<nav className={styles.bar} aria-label={groupLabel}>
<button className={styles.fab} type="button" aria-label={topLabel} onClick={toTop}>
<span aria-hidden="true"><ArrowUpIcon /></span>
</button>
<button className={styles.fab} type="button" aria-label={findLabel}>
<span aria-hidden="true"><SearchIcon /></span>
</button>
<button className={styles.fab} type="button" aria-label={tocLabel}>
<span aria-hidden="true"><ListIcon /></span>
</button>
</nav>
</section>
);
}02 · Toolbar FABs that read which way you scroll — Measured area 14.57% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
A 2px threshold keeps a trembling finger from flipping the bar on every pixel.
var tbLast = tbView.scrollTop;
tbView.addEventListener('scroll', function () {
var now = tbView.scrollTop;
var down = now - tbLast > 2;
var up = tbLast - now > 2;
tbLast = now;
if (down && !tb.classList.contains('is-hidden')) {
tb.classList.add('is-hidden');
} else if (up && tb.classList.contains('is-hidden')) {
tb.classList.remove('is-hidden');
}
});
document.getElementById('tb-top').addEventListener('click', function () {
tbView.scrollTo({ top: 0, behavior: 'smooth' });
});
03Icon ripple spreading from the exact press point
What moves
A wave that always starts dead center throws away the one thing the press knew, which is where it landed. pointerdown measures the point against the button's getBoundingClientRect(), writes it into --x and --y, and a 16px circle grows from there by scale(9) over 600ms before it is removed.
<main class="stage">
<section class="rp is-demo" id="rp" aria-label="An icon button rippling from the press point">
<button class="rp__btn" type="button" id="rp-btn" aria-label="Open alerts">
<span class="rp__seed" aria-hidden="true"></span>
<span class="rp__seed rp__seed--b" aria-hidden="true"></span>
<span class="rp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" 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,208,192Z" opacity=".2"/><path d="M221.8,175.94C216.25,166.38,208,139.33,208,104a80,80,0,1,0-160,0c0,35.34-8.26,62.38-13.81,71.94A16,16,0,0,0,48,200H88.81a40,40,0,0,0,78.38,0H208a16,16,0,0,0,13.8-24.06ZM128,216a24,24,0,0,1-22.62-16h45.24A24,24,0,0,1,128,216ZM48,184c7.7-13.24,16-43.92,16-80a64,64,0,1,1,128,0c0,36.05,8.28,66.73,16,80Z"/></svg></span>
<span class="rp__dot" aria-hidden="true"></span>
</button>
<p class="rp__hint" id="rp-hint">Press anywhere on the button</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 눌린 자리에서 번지는 아이콘 리플 — 원은 transform · opacity 로만 움직인다
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
.rp {
display: grid;
justify-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rp__btn {
position: relative;
display: inline-grid;
place-items: center;
width: 96px;
height: 96px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: $subject-ink;
color: $color;
font-size: 46px;
line-height: 1;
cursor: pointer;
overflow: hidden;
transition: transform $dur-instant $ease-pop;
}
.rp__btn:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.rp__btn:active { transform: scale(.93); }
.rp__ic { display: inline-flex; }
.rp__dot {
position: absolute;
top: $sp-2;
right: $sp-2;
width: 10px;
height: 10px;
border-radius: 50%;
background: $stage-orange;
}
// 눌린 지점에서 퍼지는 원 — 좌표는 --x · --y 로 받고 크기는 scale
.rp__wave,
.rp__seed {
position: absolute;
left: var(--x);
top: var(--y);
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(255, 247, 230, .5);
transform: translate(-50%, -50%) scale(0);
pointer-events: none;
}
@keyframes rp-grow {
0% { transform: translate(-50%, -50%) scale(0); opacity: .55; }
70% { opacity: .2; }
100% { transform: translate(-50%, -50%) scale(9); opacity: 0; }
}
.rp__wave { animation-name: rp-grow; animation-duration: 600ms; animation-timing-function: $ease-out; animation-iteration-count: 1; }
.rp__hint {
margin: 0;
color: $subject-ink;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
text-align: center;
}
// ── 격자 미리보기 전용 2초 루프: 서로 다른 지점 두 곳에서 번진다 ──
.rp__seed {
--x: 30%;
--y: 34%;
}
.rp__seed.rp__seed--b { --x: 62%; --y: 66%; }
@keyframes rp-seed-a {
0%, 16% { transform: translate(-50%, -50%) scale(0); opacity: 0; }
17% { opacity: .55; }
17.01% { transform: translate(-50%, -50%) scale(0); }
46%, 100% { transform: translate(-50%, -50%) scale(9); opacity: 0; }
}
@keyframes rp-seed-b {
0%, 56% { transform: translate(-50%, -50%) scale(0); opacity: 0; }
57% { opacity: .55; }
57.01% { transform: translate(-50%, -50%) scale(0); }
86%, 100% { transform: translate(-50%, -50%) scale(9); opacity: 0; }
}
@keyframes rp-press {
0%, 15% { transform: scale(1); }
18% { transform: scale(.93); }
26%, 55% { transform: scale(1); }
58% { transform: scale(.93); }
66%, 100% { transform: scale(1); }
}
.rp.is-demo .rp__seed:not(.rp__seed--b) {
animation-name: rp-seed-a;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.rp.is-demo .rp__seed--b {
animation-name: rp-seed-b;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.rp.is-demo .rp__btn {
animation-name: rp-press;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.rp__btn { width: 76px; height: 76px; font-size: 36px; }
.rp__hint { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.rp.is-demo .rp__seed,
.rp.is-demo .rp__btn { animation: none !important; transform: none !important; }
.rp__wave { animation: none !important; opacity: 0 !important; }
.rp__seed { opacity: 0 !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);
}
.rp {
display: grid;
justify-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rp__btn {
position: relative;
display: inline-grid;
place-items: center;
width: 96px;
height: 96px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: #17141a;
color: #fff7e6;
font-size: 46px;
line-height: 1;
cursor: pointer;
overflow: hidden;
transition: transform 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rp__btn:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.rp__btn:active {
transform: scale(0.93);
}
.rp__ic {
display: inline-flex;
}
.rp__dot {
position: absolute;
top: 8px;
right: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #ff4d1f;
}
.rp__wave,
.rp__seed {
position: absolute;
left: var(--x);
top: var(--y);
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.5);
transform: translate(-50%, -50%) scale(0);
pointer-events: none;
}
@keyframes rp-grow {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 0.55;
}
70% {
opacity: 0.2;
}
100% {
transform: translate(-50%, -50%) scale(9);
opacity: 0;
}
}
.rp__wave {
animation-name: rp-grow;
animation-duration: 600ms;
animation-timing-function: ease-out;
animation-iteration-count: 1;
}
.rp__hint {
margin: 0;
color: #17141a;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
text-align: center;
}
.rp__seed {
--x: 30%;
--y: 34%;
}
.rp__seed.rp__seed--b {
--x: 62%;
--y: 66%;
}
@keyframes rp-seed-a {
0%, 16% {
transform: translate(-50%, -50%) scale(0);
opacity: 0;
}
17% {
opacity: 0.55;
}
17.01% {
transform: translate(-50%, -50%) scale(0);
}
46%, 100% {
transform: translate(-50%, -50%) scale(9);
opacity: 0;
}
}
@keyframes rp-seed-b {
0%, 56% {
transform: translate(-50%, -50%) scale(0);
opacity: 0;
}
57% {
opacity: 0.55;
}
57.01% {
transform: translate(-50%, -50%) scale(0);
}
86%, 100% {
transform: translate(-50%, -50%) scale(9);
opacity: 0;
}
}
@keyframes rp-press {
0%, 15% {
transform: scale(1);
}
18% {
transform: scale(0.93);
}
26%, 55% {
transform: scale(1);
}
58% {
transform: scale(0.93);
}
66%, 100% {
transform: scale(1);
}
}
.rp.is-demo .rp__seed:not(.rp__seed--b) {
animation-name: rp-seed-a;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.rp.is-demo .rp__seed--b {
animation-name: rp-seed-b;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.rp.is-demo .rp__btn {
animation-name: rp-press;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.rp__btn {
width: 76px;
height: 76px;
font-size: 36px;
}
.rp__hint {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.rp.is-demo .rp__seed,
.rp.is-demo .rp__btn {
animation: none !important;
transform: none !important;
}
.rp__wave {
animation: none !important;
opacity: 0 !important;
}
.rp__seed {
opacity: 0 !important;
}
}// 눌린 지점 리플: pointerdown 이 좌표를 잡아 --x · --y 로 옮겨 적는다 (10줄 안팎)
var rp = document.getElementById('rp');
var rpBtn = document.getElementById('rp-btn');
var rpHint = document.getElementById('rp-hint');
rpBtn.addEventListener('pointerdown', function (e) {
rp.classList.remove('is-demo');
var box = rpBtn.getBoundingClientRect();
var dot = document.createElement('span');
dot.className = 'rp__wave';
dot.style.setProperty('--x', (e.clientX - box.left) + 'px');
dot.style.setProperty('--y', (e.clientY - box.top) + 'px');
rpBtn.appendChild(dot);
window.setTimeout(function () { dot.remove(); }, 620);
rpHint.textContent = 'Alerts checked';
});
rpBtn.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
rpHint.textContent = 'Alerts checked';
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./PointRippleButton.module.scss";
export type PointRippleButtonProps = {
buttonLabel?: string;
hintIdle?: string;
hintDone?: string;
groupLabel?: string;
/** 오른쪽 위 안 읽은 표시 */
unread?: boolean;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type WaveVars = CSSProperties & { "--x": string; "--y": string };
// ambient 가 얇아 KeyboardEvent 를 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type KeyEvt = { key: string };
type Wave = { id: number; x: number; y: number };
export default function PointRippleButton({
buttonLabel = "Open notifications",
hintIdle = "Press anywhere on the button",
hintDone = "Notifications opened",
groupLabel = "Icon button that ripples from the pressed point",
unread = true,
demo = true,
duration = 300,
easing = "ease-out",
color = "#fff7e6",
}: PointRippleButtonProps) {
const [waves, setWaves] = useState<Wave[]>([]);
const [live, setLive] = useState(demo);
const [hint, setHint] = useState(hintIdle);
const seq = useRef<number>(0);
const timers = useRef<number[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const life = duration * 2; // 리플 한 번의 수명 — scss 의 calc(var(--duration) * 2) 와 같다
useEffect(() => () => {
const list = timers.current;
if (!list) return;
for (const t of list) window.clearTimeout(t);
}, []);
// 눌린 지점을 --x · --y 로 옮겨 적는다. 원은 transform · opacity 로만 커진다
function onPointerDown(e: PointerEvent<HTMLButtonElement>) {
setLive(false);
const box = e.currentTarget.getBoundingClientRect();
const id = (seq.current === null ? 0 : seq.current) + 1; // useRef 는 T|null 이라 null 가드
seq.current = id;
setWaves((prev) => prev.concat([{ id, x: e.clientX - box.left, y: e.clientY - box.top }]));
setHint(hintDone);
const t = window.setTimeout(() => {
setWaves((prev) => prev.filter((w) => w.id !== id));
}, life + 20);
const list = timers.current;
if (list) list.push(t);
}
function onKeyDown(e: KeyEvt) {
if (e.key !== "Enter" && e.key !== " ") return;
setLive(false);
setHint(hintDone);
}
const cls = [styles.rp, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<button
className={styles.btn}
type="button"
aria-label={buttonLabel}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
>
<span className={styles.seed} aria-hidden="true" />
<span className={[styles.seed, styles.seedB].join(" ")} aria-hidden="true" />
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="1em" height="1em" 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,208,192Z" opacity=".2"/><path d="M221.8,175.94C216.25,166.38,208,139.33,208,104a80,80,0,1,0-160,0c0,35.34-8.26,62.38-13.81,71.94A16,16,0,0,0,48,200H88.81a40,40,0,0,0,78.38,0H208a16,16,0,0,0,13.8-24.06ZM128,216a24,24,0,0,1-22.62-16h45.24A24,24,0,0,1,128,216ZM48,184c7.7-13.24,16-43.92,16-80a64,64,0,1,1,128,0c0,36.05,8.28,66.73,16,80Z"/></svg>
</span>
{unread && <span className={styles.dot} aria-hidden="true" />}
{waves.map((w) => {
const wv: WaveVars = { "--x": `${w.x}px`, "--y": `${w.y}px` };
return <span className={styles.wave} style={wv} key={w.id} aria-hidden="true" />;
})}
</button>
<p className={styles.hint}>{hint}</p>
</section>
);
}03 · Icon ripple spreading from the exact press point — Measured area 5.137% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Nothing in that path touches layout, since only transform and opacity change.
.rp__wave,
.rp__seed {
position: absolute;
left: var(--x);
top: var(--y);
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.5);
transform: translate(-50%, -50%) scale(0);
pointer-events: none;
}
@keyframes rp-grow {
0% { transform: translate(-50%, -50%) scale(0); opacity: 0.55; }
70% { opacity: 0.2; }
100% { transform: translate(-50%, -50%) scale(9); opacity: 0; }
}
04Favorite star that draws its line, then fills
What you see
A star that turns yellow in an instant reports the result but never shows the act. The outline travels from stroke-dashoffset: 100 to zero across 460ms, and the filled copy sits behind a 0ms 460ms delay so it appears the moment the line closes, not while the line is still being drawn.
<main class="stage">
<section class="st is-demo" id="st" aria-label="A product card kept in favorites">
<article class="st__card">
<span class="st__thumb" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M224,56V178.06l-39.72-39.72a8,8,0,0,0-11.31,0L147.31,164,97.66,114.34a8,8,0,0,0-11.32,0L32,168.69V56a8,8,0,0,1,8-8H216A8,8,0,0,1,224,56Z" opacity=".2"/><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,16V158.75l-26.07-26.06a16,16,0,0,0-22.63,0l-20,20-44-44a16,16,0,0,0-22.62,0L40,149.37V56ZM40,172l52-52,80,80H40Zm176,28H194.63l-36-36,20-20L216,181.38V200ZM144,100a12,12,0,1,1,12,12A12,12,0,0,1,144,100Z"/></svg></span>
<div class="st__body">
<h2 class="st__title">Spring garden dress</h2>
<p class="st__meta">Pale mint · one size</p>
</div>
<button class="st__btn" type="button" id="st-btn" aria-pressed="false" aria-label="Add to favorites">
<span class="st__star" aria-hidden="true">
<svg class="st__base" viewBox="0 0 256 256" width="1em" height="1em"><path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Z"/></svg>
<svg class="st__draw" viewBox="0 0 256 256" width="1em" height="1em"><path pathLength="100" d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Z"/></svg>
<svg class="st__fill" viewBox="0 0 256 256" width="1em" height="1em" aria-hidden="true"><path pathLength="100" d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Z"/></svg>
</span>
</button>
<p class="st__saved" id="st-saved" aria-live="polite">Added to favorites</p>
</article>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 선을 그려 채우는 즐겨찾기 별 — 그리기는 stroke-dashoffset, 채움 전환은 컷
$duration: 460ms;
$easing: $ease-out;
$color: $stage-yellow;
@include stage($stage-paper);
.st { display: grid; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; }
.st__card {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: $sp-3;
box-sizing: border-box;
width: min(292px, 92vw);
padding: $sp-4;
border: 1px solid rgba(23, 20, 26, .16);
border-radius: $r-card;
background: $stage-paper;
transition: background 0ms 460ms;
}
.st__thumb {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: $r-control;
background: rgba(47, 109, 246, .14);
color: $subject-blue;
font-size: 26px;
}
.st__body { min-width: 0; }
.st__title {
margin: 0;
color: $subject-ink;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.st__meta {
margin: 0;
color: rgba(23, 20, 26, .72);
font-size: 11px;
line-height: 1.35;
}
// ── 별 버튼: 두 별을 겹쳐 두고 그리기 → 채움 컷 순서로 바뀐다 ──
.st__btn {
position: relative;
display: inline-grid;
place-items: center;
width: 48px;
height: 48px;
margin: 0;
padding: 0;
border: 1px solid rgba(23, 20, 26, .18);
border-radius: 50%;
background: $stage-paper;
cursor: pointer;
}
.st__btn:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.st__star {
position: relative;
display: inline-block;
width: 30px;
height: 30px;
font-size: 30px;
}
.st__base,
.st__draw,
.st__fill {
position: absolute;
inset: 0;
display: block;
}
// 담기 전에도 별자리가 보여야 한다 — 옅은 윤곽을 늘 깔아 둔다
.st__base path {
fill: none;
stroke: rgba(23, 20, 26, .28);
stroke-width: 10;
stroke-linejoin: round;
}
// 그려지는 테두리 — pathLength="100" 덕에 100 에서 0 으로 그린다
.st__draw path {
fill: none;
stroke: $subject-ink;
stroke-width: 10;
stroke-linecap: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
transition: stroke-dashoffset $duration $easing;
}
// 채워진 별 — 반투명 섞임 없이 컷으로 바뀐다
.st__fill path {
fill: $color;
stroke: $subject-ink;
stroke-width: 10;
}
.st__fill { opacity: 0; transition: opacity 0ms $duration; }
.st__saved {
position: absolute;
right: $sp-2;
bottom: $sp-1;
margin: 0;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-size: 10px;
font-weight: 700;
line-height: 1.6;
opacity: 0;
}
// 정적 경로 — .is-on 에서 그리기가 끝나고 채움·팝·카드 물들임이 남는다
.st.is-on .st__draw path { stroke-dashoffset: 0; }
.st.is-on .st__fill { opacity: 1; }
.st.is-on .st__star { animation-name: st-pop; animation-duration: $dur-quick; animation-timing-function: $ease-pop; animation-iteration-count: 1; }
.st.is-on .st__card { background: rgba(255, 210, 63, .22); }
.st.is-on .st__saved { opacity: 1; transition: opacity 0ms $duration; }
@keyframes st-pop {
0% { transform: scale(1); }
55% { transform: scale(1.22); }
100% { transform: scale(1); }
}
// ── 격자 미리보기 전용 2초 루프: 그리기 → 채움 컷 → 팝 → 되돌림 ──
@keyframes st-loop-draw {
0%, 10% { stroke-dashoffset: 100; }
42%, 82% { stroke-dashoffset: 0; }
94%, 100% { stroke-dashoffset: 100; }
}
@keyframes st-loop-fill {
0%, 41% { opacity: 0; }
42%, 81% { opacity: 1; }
82%, 100% { opacity: 0; }
}
@keyframes st-loop-card {
0%, 41% { background: $stage-paper; }
42%, 81% { background: rgba(255, 210, 63, .22); }
82%, 100% { background: $stage-paper; }
}
@keyframes st-loop-saved {
0%, 44% { opacity: 0; }
46%, 80% { opacity: 1; }
82%, 100% { opacity: 0; }
}
@keyframes st-loop-pop {
0%, 42% { transform: scale(1); }
50% { transform: scale(1.22); }
58%, 100% { transform: scale(1); }
}
.st.is-demo {
.st__draw path {
animation-name: st-loop-draw;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.st__fill {
animation-name: st-loop-fill;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st__card {
animation-name: st-loop-card;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st__saved {
animation-name: st-loop-saved;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st__star {
animation-name: st-loop-pop;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.st__card { padding: $sp-3; gap: $sp-2; }
.st__thumb { width: 36px; height: 36px; font-size: 22px; }
.st__title { font-size: 12px; }
.st__btn { width: 40px; height: 40px; }
.st__star { width: 24px; height: 24px; font-size: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.st.is-demo .st__draw path,
.st.is-demo .st__fill,
.st.is-demo .st__card,
.st.is-demo .st__saved,
.st.is-demo .st__star { animation: none !important; transform: none !important; }
// 그린 결과만 남긴다 — 테두리는 완성, 채움은 켜짐
.st.is-demo .st__draw path { stroke-dashoffset: 0 !important; }
.st.is-demo .st__fill { opacity: 1 !important; }
.st.is-demo .st__card { background: rgba(255, 210, 63, .22) !important; }
.st.is-demo .st__saved { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.st {
display: grid;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.st__card {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
box-sizing: border-box;
width: min(292px, 92vw);
padding: 16px;
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 16px;
background: #ffffff;
transition: background 0ms 460ms;
}
.st__thumb {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
font-size: 26px;
}
.st__body {
min-width: 0;
}
.st__title {
margin: 0;
color: #17141a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.st__meta {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
line-height: 1.35;
}
.st__btn {
position: relative;
display: inline-grid;
place-items: center;
width: 48px;
height: 48px;
margin: 0;
padding: 0;
border: 1px solid rgba(23, 20, 26, 0.18);
border-radius: 50%;
background: #ffffff;
cursor: pointer;
}
.st__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.st__star {
position: relative;
display: inline-block;
width: 30px;
height: 30px;
font-size: 30px;
}
.st__base,
.st__draw,
.st__fill {
position: absolute;
inset: 0;
display: block;
}
.st__base path {
fill: none;
stroke: rgba(23, 20, 26, 0.28);
stroke-width: 10;
stroke-linejoin: round;
}
.st__draw path {
fill: none;
stroke: #17141a;
stroke-width: 10;
stroke-linecap: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
transition: stroke-dashoffset 460ms ease-out;
}
.st__fill path {
fill: #ffd23f;
stroke: #17141a;
stroke-width: 10;
}
.st__fill {
opacity: 0;
transition: opacity 0ms 460ms;
}
.st__saved {
position: absolute;
right: 8px;
bottom: 4px;
margin: 0;
padding: 0 8px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-size: 10px;
font-weight: 700;
line-height: 1.6;
opacity: 0;
}
.st.is-on .st__draw path {
stroke-dashoffset: 0;
}
.st.is-on .st__fill {
opacity: 1;
}
.st.is-on .st__star {
animation-name: st-pop;
animation-duration: 220ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
.st.is-on .st__card {
background: rgba(255, 210, 63, 0.22);
}
.st.is-on .st__saved {
opacity: 1;
transition: opacity 0ms 460ms;
}
@keyframes st-pop {
0% {
transform: scale(1);
}
55% {
transform: scale(1.22);
}
100% {
transform: scale(1);
}
}
@keyframes st-loop-draw {
0%, 10% {
stroke-dashoffset: 100;
}
42%, 82% {
stroke-dashoffset: 0;
}
94%, 100% {
stroke-dashoffset: 100;
}
}
@keyframes st-loop-fill {
0%, 41% {
opacity: 0;
}
42%, 81% {
opacity: 1;
}
82%, 100% {
opacity: 0;
}
}
@keyframes st-loop-card {
0%, 41% {
background: #ffffff;
}
42%, 81% {
background: rgba(255, 210, 63, 0.22);
}
82%, 100% {
background: #ffffff;
}
}
@keyframes st-loop-saved {
0%, 44% {
opacity: 0;
}
46%, 80% {
opacity: 1;
}
82%, 100% {
opacity: 0;
}
}
@keyframes st-loop-pop {
0%, 42% {
transform: scale(1);
}
50% {
transform: scale(1.22);
}
58%, 100% {
transform: scale(1);
}
}
.st.is-demo .st__draw path {
animation-name: st-loop-draw;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.st.is-demo .st__fill {
animation-name: st-loop-fill;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st.is-demo .st__card {
animation-name: st-loop-card;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st.is-demo .st__saved {
animation-name: st-loop-saved;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st.is-demo .st__star {
animation-name: st-loop-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.st__card {
padding: 12px;
gap: 8px;
}
.st__thumb {
width: 36px;
height: 36px;
font-size: 22px;
}
.st__title {
font-size: 12px;
}
.st__btn {
width: 40px;
height: 40px;
}
.st__star {
width: 24px;
height: 24px;
font-size: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.st.is-demo .st__draw path,
.st.is-demo .st__fill,
.st.is-demo .st__card,
.st.is-demo .st__saved,
.st.is-demo .st__star {
animation: none !important;
transform: none !important;
}
.st.is-demo .st__draw path {
stroke-dashoffset: 0 !important;
}
.st.is-demo .st__fill {
opacity: 1 !important;
}
.st.is-demo .st__card {
background: rgba(255, 210, 63, 0.22) !important;
}
.st.is-demo .st__saved {
opacity: 1 !important;
}
}// 즐겨찾기 별: 누르면 테두리가 그려지고(step 1) 다 그린 뒤 채움으로 바뀐다(step 2)
var st = document.getElementById('st');
var stBtn = document.getElementById('st-btn');
var stSaved = document.getElementById('st-saved');
var stOn = false;
stBtn.addEventListener('click', function () {
st.classList.remove('is-demo');
stOn = !stOn;
st.classList.toggle('is-on', stOn);
stBtn.setAttribute('aria-pressed', stOn ? 'true' : 'false');
stBtn.setAttribute('aria-label', stOn ? 'Remove from favorites' : 'Add to favorites');
stSaved.textContent = stOn ? 'Added to favorites' : 'Removed from favorites';
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StarDrawButton.module.scss";
export type StarDrawButtonProps = {
title?: string;
meta?: string;
/** 꺼진 상태의 버튼 이름 (누르면 담긴다) */
addLabel?: string;
/** 켜진 상태의 버튼 이름 (누르면 빠진다) */
removeLabel?: string;
savedText?: string;
removedText?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
/** 테두리를 그리는 시간(ms). 채움 컷은 이 시간이 끝난 뒤에 온다 */
duration?: number;
easing?: string;
/** 다 그린 별의 채움색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// vanilla 판 index.html 에 인라인된 Phosphor Duotone 별 — 테두리용·채움용 두 장이 같은 경로를 쓴다
const STAR =
"M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Z";
function Thumb() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path
d="M224,56V178.06l-39.72-39.72a8,8,0,0,0-11.31,0L147.31,164,97.66,114.34a8,8,0,0,0-11.32,0L32,168.69V56a8,8,0,0,1,8-8H216A8,8,0,0,1,224,56Z"
opacity=".2"
/>
<path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,16V158.75l-26.07-26.06a16,16,0,0,0-22.63,0l-20,20-44-44a16,16,0,0,0-22.62,0L40,149.37V56ZM40,172l52-52,80,80H40Zm176,28H194.63l-36-36,20-20L216,181.38V200ZM144,100a12,12,0,1,1,12,12A12,12,0,0,1,144,100Z" />
</svg>
);
}
export default function StarDrawButton({
title = "Spring garden dress",
meta = "Soft mint · one size",
addLabel = "Add to favorites",
removeLabel = "Remove from favorites",
savedText = "Saved to favorites",
removedText = "Removed from favorites",
groupLabel = "Product card you can save to favorites",
demo = true,
duration = 460,
easing = "ease-out",
color = "#ffd23f",
}: StarDrawButtonProps) {
const [on, setOn] = useState(false);
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 누르면 미리보기 루프를 끄고 사람이 만든 상태만 남긴다
function toggle() {
setLive(false);
setOn((v) => !v);
}
const cls = [styles.st, live ? styles.isDemo : "", on ? styles.isOn : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<article className={styles.card}>
<span className={styles.thumb} aria-hidden="true">
<Thumb />
</span>
<div className={styles.body}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.meta}>{meta}</p>
</div>
<button
className={styles.btn}
type="button"
aria-pressed={on}
aria-label={on ? removeLabel : addLabel}
onClick={toggle}
>
<span className={styles.star} aria-hidden="true">
{/* 바탕 — 담기 전에도 별자리가 보이게 옅은 윤곽을 늘 깔아 둔다 */}
<svg className={styles.base} viewBox="0 0 256 256" width="1em" height="1em">
<path d={STAR} />
</svg>
{/* 테두리 — pathLength 100 이라 100 → 0 으로 한 바퀴 그려진다 */}
<svg className={styles.draw} viewBox="0 0 256 256" width="1em" height="1em">
<path pathLength={100} d={STAR} />
</svg>
{/* 채움 — 반투명으로 섞지 않고 그리기가 끝난 뒤 컷으로 나타난다 */}
<svg className={styles.fill} viewBox="0 0 256 256" width="1em" height="1em" aria-hidden="true">
<path pathLength={100} d={STAR} />
</svg>
</span>
</button>
<p className={styles.saved} aria-live="polite">
{on ? savedText : removedText}
</p>
</article>
</section>
);
}04 · Favorite star that draws its line, then fills — Measured area 15.502% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The preview moves in 16 of its 24 frames, and the card background takes the same yellow at that instant.
.st__draw path {
fill: none;
stroke: #17141a;
stroke-width: 10;
stroke-linecap: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
transition: stroke-dashoffset 460ms ease-out;
}
.st__fill {
opacity: 0;
transition: opacity 0ms 460ms;
}
.st.is-on .st__draw path { stroke-dashoffset: 0; }
.st.is-on .st__fill { opacity: 1; }
05Theme button where the sun and moon trade places
What happens on screen
Cross-fading a sun into a moon produces a frame where both are half there, and that ghost is exactly what a poster grab picks up. Both icons share one grid cell and swap through transition: opacity 0ms, while the card and the day/night label behind them are cut by steps(1, end); rotation and scale keep their easing and stay smooth.
<main class="stage">
<section class="tm is-demo" id="tm" aria-label="A theme button where sun and moon trade places">
<article class="tm__card">
<header class="tm__head">
<span class="tm__avatar" aria-hidden="true"></span>
<span class="tm__who">My notes</span>
</header>
<p class="tm__line tm__line--a">Bright paper by day</p>
<p class="tm__line tm__line--b">Soft ink by night</p>
<p class="tm__line tm__line--c">Whichever rests the eyes</p>
</article>
<button class="tm__btn" type="button" id="tm-btn" aria-pressed="false" aria-label="Switch to the night theme">
<span class="tm__disc" aria-hidden="true">
<svg class="tm__sun" viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M184,128a56,56,0,1,1-56-56A56,56,0,0,1,184,128Z" opacity=".2"/><path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"/></svg>
<svg class="tm__moon" viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M227.89,147.89A96,96,0,1,1,108.11,28.11,96.09,96.09,0,0,0,227.89,147.89Z" opacity=".2"/><path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z"/></svg>
</span>
<span class="tm__t"><span class="tm__t-day">Day</span><span class="tm__t-night">Night</span></span>
</button>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
// 해와 달이 맞바뀌는 테마 버튼 — 아이콘·카드 전환은 전부 steps(1, end) 컷
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
$card-day: $subject-cream;
$card-night: color.mix($subject-lilac, $stage-ink, 18%);
.tm {
display: grid;
justify-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tm__card {
box-sizing: border-box;
width: min(236px, 92vw);
padding: $sp-3;
border-radius: $r-card;
background: $card-day;
color: $subject-ink;
}
.tm__head {
display: flex;
align-items: center;
gap: $sp-2;
margin-bottom: $sp-2;
}
.tm__avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: $subject-blue;
}
.tm__who { font-size: 11px; font-weight: 700; line-height: 1.3; }
.tm__line { margin: 0; font-size: 12px; line-height: 1.5; }
.tm__line--a { opacity: .95; }
.tm__line--b { opacity: .7; }
.tm__line--c { opacity: .45; }
// ── 토글 버튼: 해와 달 두 아이콘을 겹쳐 두고 컷으로 맞바꾼다 ──
.tm__btn {
display: inline-flex;
align-items: center;
gap: $sp-2;
margin: 0;
padding: $sp-1 $sp-3 $sp-1 $sp-1;
border: 1px solid rgba(255, 247, 230, .24);
border-radius: $r-pill;
background: color.mix($subject-cream, $stage-ink, 12%);
color: $subject-cream;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.tm__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.tm__disc {
position: relative;
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: $stage-yellow;
color: $subject-ink;
font-size: 20px;
transition: transform $dur-base $easing;
}
.tm__sun,
.tm__moon {
position: absolute;
grid-area: 1 / 1;
transition: opacity 0ms, transform $dur-base $easing;
}
.tm__moon { opacity: 0; transform: rotate(-90deg) scale(.4); }
// 낮·밤 글자도 컷으로 맞바뀐다
.tm__t { position: relative; display: inline-grid; }
.tm__t-day,
.tm__t-night { grid-area: 1 / 1; }
.tm__t-night { opacity: 0; }
.tm.is-dark .tm__t-day { opacity: 0; }
.tm.is-dark .tm__t-night { opacity: 1; }
// 정적 경로 — 밤이면 달이 살고 해가 비켜난다 (컷)
.tm.is-dark .tm__card { background: $card-night; color: $subject-cream; }
.tm.is-dark .tm__sun { opacity: 0; transform: rotate(90deg) scale(.4); }
.tm.is-dark .tm__moon { opacity: 1; transform: rotate(0deg) scale(1); }
.tm.is-dark .tm__disc { background: $subject-lilac; color: $stage-ink; transform: rotate(180deg); }
// ── 격자 미리보기 전용 2초 루프: 낮 → 컷 → 밤 → 컷 → 낮 ──
// 컷(41%/82%) 앞뒤로 회전 구간을 넓게 둬 프레임 수를 채운다
@keyframes tm-loop-sun {
0%, 36% { opacity: 1; transform: rotate(0deg) scale(1); }
52%, 78% { opacity: 0; transform: rotate(90deg) scale(.4); }
94%, 100% { opacity: 1; transform: rotate(0deg) scale(1); }
}
@keyframes tm-loop-moon {
0%, 36% { opacity: 0; transform: rotate(-90deg) scale(.4); }
52%, 78% { opacity: 1; transform: rotate(0deg) scale(1); }
94%, 100% { opacity: 0; transform: rotate(-90deg) scale(.4); }
}
@keyframes tm-loop-card {
0%, 44% { background: $card-day; color: $subject-ink; }
45%, 79% { background: $card-night; color: $subject-cream; }
80%, 100% { background: $card-day; color: $subject-ink; }
}
@keyframes tm-loop-disc {
0%, 36% { background: $stage-yellow; color: $subject-ink; transform: rotate(0deg); }
52%, 78% { background: $subject-lilac; color: $stage-ink; transform: rotate(180deg); }
94%, 100% { background: $stage-yellow; color: $subject-ink; transform: rotate(360deg); }
}
@keyframes tm-loop-day {
0%, 44% { opacity: 1; }
45%, 79% { opacity: 0; }
80%, 100% { opacity: 1; }
}
@keyframes tm-loop-night {
0%, 44% { opacity: 0; }
45%, 79% { opacity: 1; }
80%, 100% { opacity: 0; }
}
.tm.is-demo {
.tm__t-day {
animation-name: tm-loop-day;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tm__t-night {
animation-name: tm-loop-night;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tm__sun {
animation-name: tm-loop-sun;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.tm__moon {
animation-name: tm-loop-moon;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.tm__card {
animation-name: tm-loop-card;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tm__disc {
animation-name: tm-loop-disc;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.tm__card { padding: $sp-2; width: min(224px, 92vw); }
.tm__line { font-size: 11px; }
.tm__btn { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.tm.is-demo .tm__sun,
.tm.is-demo .tm__moon,
.tm.is-demo .tm__card,
.tm.is-demo .tm__disc,
.tm.is-demo .tm__t-day,
.tm.is-demo .tm__t-night { animation: none !important; transform: none !important; }
// 밤 상태의 결과를 남긴다 — 달이 보이고 카드는 어둡다
.tm.is-demo .tm__sun { opacity: 0 !important; }
.tm.is-demo .tm__moon { opacity: 1 !important; }
.tm.is-demo .tm__card { background: $card-night !important; color: $subject-cream !important; }
.tm.is-demo .tm__disc { background: $subject-lilac !important; }
.tm.is-demo .tm__t-day { opacity: 0 !important; }
.tm.is-demo .tm__t-night { opacity: 1 !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);
}
.tm {
display: grid;
justify-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tm__card {
box-sizing: border-box;
width: min(236px, 92vw);
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
}
.tm__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.tm__avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: #2f6df6;
}
.tm__who {
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.tm__line {
margin: 0;
font-size: 12px;
line-height: 1.5;
}
.tm__line--a {
opacity: 0.95;
}
.tm__line--b {
opacity: 0.7;
}
.tm__line--c {
opacity: 0.45;
}
.tm__btn {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 4px 12px 4px 4px;
border: 1px solid rgba(255, 247, 230, 0.24);
border-radius: 999px;
background: rgb(19.937254902%, 18.5254901961%, 19.7960784314%);
color: #fff7e6;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.tm__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.tm__disc {
position: relative;
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: #ffd23f;
color: #17141a;
font-size: 20px;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tm__sun,
.tm__moon {
position: absolute;
grid-area: 1/1;
transition: opacity 0ms, transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tm__moon {
opacity: 0;
transform: rotate(-90deg) scale(0.4);
}
.tm__t {
position: relative;
display: inline-grid;
}
.tm__t-day,
.tm__t-night {
grid-area: 1/1;
}
.tm__t-night {
opacity: 0;
}
.tm.is-dark .tm__t-day {
opacity: 0;
}
.tm.is-dark .tm__t-night {
opacity: 1;
}
.tm.is-dark .tm__card {
background: rgb(20.4549019608%, 17.7254901961%, 26.3607843137%);
color: #fff7e6;
}
.tm.is-dark .tm__sun {
opacity: 0;
transform: rotate(90deg) scale(0.4);
}
.tm.is-dark .tm__moon {
opacity: 1;
transform: rotate(0deg) scale(1);
}
.tm.is-dark .tm__disc {
background: #b9a0ff;
color: #17141a;
transform: rotate(180deg);
}
@keyframes tm-loop-sun {
0%, 36% {
opacity: 1;
transform: rotate(0deg) scale(1);
}
52%, 78% {
opacity: 0;
transform: rotate(90deg) scale(0.4);
}
94%, 100% {
opacity: 1;
transform: rotate(0deg) scale(1);
}
}
@keyframes tm-loop-moon {
0%, 36% {
opacity: 0;
transform: rotate(-90deg) scale(0.4);
}
52%, 78% {
opacity: 1;
transform: rotate(0deg) scale(1);
}
94%, 100% {
opacity: 0;
transform: rotate(-90deg) scale(0.4);
}
}
@keyframes tm-loop-card {
0%, 44% {
background: #fff7e6;
color: #17141a;
}
45%, 79% {
background: rgb(20.4549019608%, 17.7254901961%, 26.3607843137%);
color: #fff7e6;
}
80%, 100% {
background: #fff7e6;
color: #17141a;
}
}
@keyframes tm-loop-disc {
0%, 36% {
background: #ffd23f;
color: #17141a;
transform: rotate(0deg);
}
52%, 78% {
background: #b9a0ff;
color: #17141a;
transform: rotate(180deg);
}
94%, 100% {
background: #ffd23f;
color: #17141a;
transform: rotate(360deg);
}
}
@keyframes tm-loop-day {
0%, 44% {
opacity: 1;
}
45%, 79% {
opacity: 0;
}
80%, 100% {
opacity: 1;
}
}
@keyframes tm-loop-night {
0%, 44% {
opacity: 0;
}
45%, 79% {
opacity: 1;
}
80%, 100% {
opacity: 0;
}
}
.tm.is-demo .tm__t-day {
animation-name: tm-loop-day;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tm.is-demo .tm__t-night {
animation-name: tm-loop-night;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tm.is-demo .tm__sun {
animation-name: tm-loop-sun;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.tm.is-demo .tm__moon {
animation-name: tm-loop-moon;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.tm.is-demo .tm__card {
animation-name: tm-loop-card;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tm.is-demo .tm__disc {
animation-name: tm-loop-disc;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.tm__card {
padding: 8px;
width: min(224px, 92vw);
}
.tm__line {
font-size: 11px;
}
.tm__btn {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.tm.is-demo .tm__sun,
.tm.is-demo .tm__moon,
.tm.is-demo .tm__card,
.tm.is-demo .tm__disc,
.tm.is-demo .tm__t-day,
.tm.is-demo .tm__t-night {
animation: none !important;
transform: none !important;
}
.tm.is-demo .tm__sun {
opacity: 0 !important;
}
.tm.is-demo .tm__moon {
opacity: 1 !important;
}
.tm.is-demo .tm__card {
background: rgb(20.4549019608%, 17.7254901961%, 26.3607843137%) !important;
color: #fff7e6 !important;
}
.tm.is-demo .tm__disc {
background: #b9a0ff !important;
}
.tm.is-demo .tm__t-day {
opacity: 0 !important;
}
.tm.is-demo .tm__t-night {
opacity: 1 !important;
}
}// 테마 토글: 해와 달은 반투명으로 섞지 않고 한 프레임 컷으로 맞바뀐다
var tm = document.getElementById('tm');
var tmBtn = document.getElementById('tm-btn');
var tmDark = false;
tmBtn.addEventListener('click', function () {
tm.classList.remove('is-demo');
tmDark = !tmDark;
tm.classList.toggle('is-dark', tmDark);
tmBtn.setAttribute('aria-pressed', tmDark ? 'true' : 'false');
tmBtn.setAttribute('aria-label', tmDark ? 'Switch to the day theme' : 'Switch to the night theme');
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ThemeMorphButton.module.scss";
export type ThemeMorphButtonProps = {
who?: string;
/** 미리보기 판의 세 줄. 위에서 아래로 옅어진다 */
lines?: string[];
dayLabel?: string;
nightLabel?: string;
/** 낮일 때의 버튼 이름 (누르면 밤이 된다) */
toNightLabel?: string;
/** 밤일 때의 버튼 이름 (누르면 낮이 된다) */
toDayLabel?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
/** 해·달·원반이 돌아 자리를 바꾸는 시간(ms) */
duration?: number;
easing?: string;
/** 밤 원반의 색 — 초점 테두리도 같은 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_LINES = ["Bright paper by day", "Soft ink by night", "Whichever is easier on the eyes"];
function Sun({ cls }: { cls: string }) {
return (
<svg className={cls} viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path d="M184,128a56,56,0,1,1-56-56A56,56,0,0,1,184,128Z" opacity=".2" />
<path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z" />
</svg>
);
}
function Moon({ cls }: { cls: string }) {
return (
<svg className={cls} viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path d="M227.89,147.89A96,96,0,1,1,108.11,28.11,96.09,96.09,0,0,0,227.89,147.89Z" opacity=".2" />
<path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z" />
</svg>
);
}
export default function ThemeMorphButton({
who = "My notes",
lines = DEFAULT_LINES,
dayLabel = "Day",
nightLabel = "Night",
toNightLabel = "Switch to the night theme",
toDayLabel = "Switch to the day theme",
groupLabel = "Theme button where the sun and moon trade places",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#b9a0ff",
}: ThemeMorphButtonProps) {
const [dark, setDark] = useState(false);
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 누르면 미리보기 루프를 끄고 사람이 고른 테마만 남긴다
function toggle() {
setLive(false);
setDark((v) => !v);
}
const cls = [styles.tm, live ? styles.isDemo : "", dark ? styles.isDark : ""].filter(Boolean).join(" ");
const fade = [styles.lineA, styles.lineB, styles.lineC];
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<article className={styles.card}>
<header className={styles.head}>
<span className={styles.avatar} aria-hidden="true" />
<span className={styles.who}>{who}</span>
</header>
{lines.map((t, i) => (
<p key={t} className={[styles.line, fade[i] || ""].filter(Boolean).join(" ")}>
{t}
</p>
))}
</article>
<button
className={styles.btn}
type="button"
aria-pressed={dark}
aria-label={dark ? toDayLabel : toNightLabel}
onClick={toggle}
>
{/* 해와 달은 반투명으로 섞지 않고 한 프레임 컷으로 맞바뀐다 */}
<span className={styles.disc} aria-hidden="true">
<Sun cls={styles.sun} />
<Moon cls={styles.moon} />
</span>
<span className={styles.t}>
<span className={styles.tDay}>{dayLabel}</span>
<span className={styles.tNight}>{nightLabel}</span>
</span>
</button>
</section>
);
}05 · Theme button where the sun and moon trade places — Measured area 17.817% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
It carries the second-largest single-frame change of the nine, at 17.81% of the stage.
.tm__sun,
.tm__moon {
position: absolute;
grid-area: 1/1;
transition: opacity 0ms, transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tm__moon { opacity: 0; transform: rotate(-90deg) scale(0.4); }
.tm.is-dark .tm__sun { opacity: 0; transform: rotate(90deg) scale(0.4); }
.tm.is-dark .tm__moon { opacity: 1; transform: rotate(0deg) scale(1); }
.tm.is-dark .tm__disc { transform: rotate(180deg); }
06Close button with a ring that keeps circling
What moves
A close mark small enough to sit on a chip is also small enough to look like decoration. A dashed ring wraps it and turns once every 900ms, but only while the pointer hovers or :focus-visible lands, so a keyboard visitor is handed the same signal as a mouse visitor.
<main class="stage">
<section class="cl is-demo" id="cl" aria-label="A notice card with a ring-spinning close button">
<article class="cl__card">
<p class="cl__msg">Your 10% first-order coupon lasts ten more days</p>
<button class="cl__btn" type="button" id="cl-btn" aria-label="Close the notice">
<span class="cl__ring" aria-hidden="true"></span>
<span class="cl__x" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity=".2"/><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg></span>
</button>
<p class="cl__undo" id="cl-undo" aria-live="polite">Notice closed <button class="cl__back" type="button" id="cl-back">Undo</button></p>
</article>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 링이 빙글 도는 닫기 버튼 — 점선 링은 호버와 :focus-visible 에서만 돌고 카드가 진해진다
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.cl { display: grid; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; }
.cl__card {
position: relative;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: $sp-3;
box-sizing: border-box;
width: min(252px, 92vw);
min-height: 96px;
padding: $sp-4 $sp-4 $sp-4 $sp-4;
border: 1px solid rgba(23, 20, 26, .2);
border-radius: $r-card;
background: $stage-paper;
color: $subject-ink;
}
.cl__msg {
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.45;
}
.cl__undo {
grid-column: 1 / -1;
margin: 0;
font-size: 11px;
line-height: 1.6;
color: rgba(23, 20, 26, .6);
opacity: 0;
}
.cl__back {
margin: 0;
padding: 0;
border: 0;
background: none;
color: $subject-blue;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.6;
text-decoration: underline;
cursor: pointer;
}
.cl__back:focus-visible { outline: 2px solid $subject-blue; outline-offset: 1px; }
// ── 닫기 버튼과 점선 링 ──
.cl__btn {
position: relative;
display: inline-grid;
place-items: center;
width: 56px;
height: 56px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $subject-ink;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.cl__btn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 3px; }
.cl__ring {
position: absolute;
inset: 0;
border: 3px dashed currentColor;
border-radius: 50%;
opacity: .35;
transform: rotate(0deg);
}
.cl__x {
display: inline-flex;
transition: transform $dur-base $easing;
}
.cl__btn:hover .cl__ring,
.cl__btn:focus-visible .cl__ring {
opacity: 1;
animation-name: cl-spin;
animation-duration: 900ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.cl__btn:hover .cl__x,
.cl__btn:focus-visible .cl__x { transform: rotate(90deg); }
.cl__btn:active .cl__x { transform: rotate(90deg) scale(.86); }
@keyframes cl-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// 정적 경로 — 누르면 카드가 접히고 되돌리기가 남는다 (퇴장이 아니라 접힘)
.cl.is-gone .cl__card {
display: grid;
grid-template-rows: 0fr 1fr;
align-content: start;
padding: 0 $sp-5 0 $sp-4;
min-height: 0;
border-style: dashed;
overflow: hidden;
}
.cl.is-gone .cl__msg {
grid-row: 1;
min-height: 0;
overflow: hidden;
font-size: 0;
line-height: 0;
}
.cl.is-gone .cl__btn { display: none; }
.cl.is-gone .cl__undo { opacity: 1; padding: $sp-2 0; }
// ── 격자 미리보기 전용 2초 루프: 링 회전 + 카드 물들임 컷 ──
@keyframes cl-loop-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes cl-loop-x {
0%, 10% { transform: rotate(0deg); }
30%, 100% { transform: rotate(90deg); }
}
// 호버가 붙은 순간만 버튼 자리가 먹색으로 컷 전환된다 (카드 본문은 건드리지 않는다)
@keyframes cl-loop-seat {
0%, 9% { background: transparent; color: $subject-ink; }
10%, 86% { background: $subject-ink; color: $stage-yellow; }
87%, 100% { background: transparent; color: $subject-ink; }
}
.cl.is-demo {
.cl__ring {
opacity: 1;
animation-name: cl-loop-spin;
animation-duration: 900ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.cl__x {
animation-name: cl-loop-x;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.cl__btn {
animation-name: cl-loop-seat;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.cl__card { padding: $sp-3 $sp-4 $sp-3 $sp-3; min-height: 84px; }
.cl__msg { font-size: 12px; }
.cl__btn { width: 40px; height: 40px; font-size: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.cl.is-demo .cl__ring,
.cl.is-demo .cl__x,
.cl.is-demo .cl__btn { animation: none !important; transform: none !important; }
// 진해진 상태(호버 결과)만 남기고 돌기는 끈다
.cl.is-demo .cl__ring { opacity: 1 !important; }
.cl.is-demo .cl__x { transform: rotate(90deg) !important; }
.cl.is-demo .cl__btn { background: $subject-ink !important; color: $stage-yellow !important; }
.cl__btn:hover .cl__ring,
.cl__btn:focus-visible .cl__ring { animation: none !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);
}
.cl {
display: grid;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cl__card {
position: relative;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 12px;
box-sizing: border-box;
width: min(252px, 92vw);
min-height: 96px;
padding: 16px 16px 16px 16px;
border: 1px solid rgba(23, 20, 26, 0.2);
border-radius: 16px;
background: #ffffff;
color: #17141a;
}
.cl__msg {
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.45;
}
.cl__undo {
grid-column: 1/-1;
margin: 0;
font-size: 11px;
line-height: 1.6;
color: rgba(23, 20, 26, 0.6);
opacity: 0;
}
.cl__back {
margin: 0;
padding: 0;
border: 0;
background: none;
color: #2f6df6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.6;
text-decoration: underline;
cursor: pointer;
}
.cl__back:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.cl__btn {
position: relative;
display: inline-grid;
place-items: center;
width: 56px;
height: 56px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: #17141a;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.cl__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.cl__ring {
position: absolute;
inset: 0;
border: 3px dashed currentColor;
border-radius: 50%;
opacity: 0.35;
transform: rotate(0deg);
}
.cl__x {
display: inline-flex;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cl__btn:hover .cl__ring,
.cl__btn:focus-visible .cl__ring {
opacity: 1;
animation-name: cl-spin;
animation-duration: 900ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.cl__btn:hover .cl__x,
.cl__btn:focus-visible .cl__x {
transform: rotate(90deg);
}
.cl__btn:active .cl__x {
transform: rotate(90deg) scale(0.86);
}
@keyframes cl-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.cl.is-gone .cl__card {
display: grid;
grid-template-rows: 0fr 1fr;
align-content: start;
padding: 0 20px 0 16px;
min-height: 0;
border-style: dashed;
overflow: hidden;
}
.cl.is-gone .cl__msg {
grid-row: 1;
min-height: 0;
overflow: hidden;
font-size: 0;
line-height: 0;
}
.cl.is-gone .cl__btn {
display: none;
}
.cl.is-gone .cl__undo {
opacity: 1;
padding: 8px 0;
}
@keyframes cl-loop-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes cl-loop-x {
0%, 10% {
transform: rotate(0deg);
}
30%, 100% {
transform: rotate(90deg);
}
}
@keyframes cl-loop-seat {
0%, 9% {
background: transparent;
color: #17141a;
}
10%, 86% {
background: #17141a;
color: #ffd23f;
}
87%, 100% {
background: transparent;
color: #17141a;
}
}
.cl.is-demo .cl__ring {
opacity: 1;
animation-name: cl-loop-spin;
animation-duration: 900ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.cl.is-demo .cl__x {
animation-name: cl-loop-x;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.cl.is-demo .cl__btn {
animation-name: cl-loop-seat;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.cl__card {
padding: 12px 16px 12px 12px;
min-height: 84px;
}
.cl__msg {
font-size: 12px;
}
.cl__btn {
width: 40px;
height: 40px;
font-size: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl.is-demo .cl__ring,
.cl.is-demo .cl__x,
.cl.is-demo .cl__btn {
animation: none !important;
transform: none !important;
}
.cl.is-demo .cl__ring {
opacity: 1 !important;
}
.cl.is-demo .cl__x {
transform: rotate(90deg) !important;
}
.cl.is-demo .cl__btn {
background: #17141a !important;
color: #ffd23f !important;
}
.cl__btn:hover .cl__ring,
.cl__btn:focus-visible .cl__ring {
animation: none !important;
}
}// 닫기 버튼: 호버·초점엔 링이 돌고, 누르면 카드가 접히며 되돌리기가 남는다
var cl = document.getElementById('cl');
var clBtn = document.getElementById('cl-btn');
var clBack = document.getElementById('cl-back');
clBtn.addEventListener('click', function () {
cl.classList.remove('is-demo');
cl.classList.add('is-gone');
});
clBack.addEventListener('click', function () {
cl.classList.remove('is-gone');
clBtn.focus({ preventScroll: true });
});
cl.addEventListener('keydown', function (e) {
if (e.key === 'Escape') cl.classList.remove('is-gone');
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CloseRingButton.module.scss";
export type CloseRingButtonProps = {
msg?: string;
/** 닫은 뒤 남는 줄 */
goneText?: string;
undoLabel?: string;
closeLabel?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
/** 점선 링이 한 바퀴 도는 시간(ms) */
duration?: number;
easing?: string;
/** 아이콘과 초점 테두리의 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// react ambient 에 KeyboardEvent 가 없어 쓰는 자리에 맞춰 직접 좁게 선언한다
type KeyEvt = { key: string };
function X() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity=".2" />
<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />
</svg>
);
}
export default function CloseRingButton({
msg = "Your 10% first-order coupon is good for ten more days",
goneText = "Notice closed",
undoLabel = "Undo",
closeLabel = "Close notice",
groupLabel = "Notice card with a close button that keeps circling",
demo = true,
duration = 900,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: CloseRingButtonProps) {
const [gone, setGone] = useState(false);
const [live, setLive] = useState(demo);
const [refocus, setRefocus] = useState(false);
const btnRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 닫기 버튼은 접힌 뒤 display:none 이라, 되돌린 다음 프레임에서 초점을 준다
useEffect(() => {
if (!refocus) return;
setRefocus(false);
if (btnRef.current) btnRef.current.focus({ preventScroll: true });
}, [refocus]);
function close() {
setLive(false);
setGone(true);
}
function undo() {
setGone(false);
setRefocus(true);
}
function onKey(e: KeyEvt) {
if (e.key === "Escape") undo();
}
const cls = [styles.cl, live ? styles.isDemo : "", gone ? styles.isGone : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel} onKeyDown={onKey}>
<article className={styles.card}>
<p className={styles.msg}>{msg}</p>
<p className={styles.undo} aria-live="polite">
{gone ? goneText : ""}{" "}
<button className={styles.back} type="button" onClick={undo}>
{undoLabel}
</button>
</p>
<button className={styles.btn} type="button" aria-label={closeLabel} onClick={close} ref={btnRef}>
{/* 호버·:focus-visible 에서만 도는 점선 링 */}
<span className={styles.ring} aria-hidden="true" />
<span className={styles.x} aria-hidden="true">
<X />
</span>
</button>
</article>
</section>
);
}06 · Close button with a ring that keeps circling — Measured area 1.828% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Nothing but the ring moves here, which is why the card is held to 252px and the button pushed out to 56px.
.cl__ring {
position: absolute;
inset: 0;
border: 3px dashed currentColor;
border-radius: 50%;
opacity: 0.35;
}
.cl__btn:hover .cl__ring,
.cl__btn:focus-visible .cl__ring {
opacity: 1;
animation-name: cl-spin;
animation-duration: 900ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes cl-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
07Search FAB that unfolds into an input
What you see
A magnifier can only become a text field if the widths in between actually exist. --w is registered through @property as a <length>, so the pill flows from 48px to 264px instead of snapping, and focus enters the field with preventScroll once the width lands.
<main class="stage">
<section class="se is-demo" id="se" aria-label="A search FAB that unfolds into an input">
<p class="se__hint" id="se-hint">Open search</p>
<div class="se__dock">
<div class="se__pill" id="se-pill">
<button class="se__go" type="button" id="se-go" aria-expanded="false" aria-controls="se-input" aria-label="Open search">
<span aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M192,112a80,80,0,1,1-80-80A80,80,0,0,1,192,112Z" opacity=".2"/><path d="M229.66,218.34,179.6,168.28a88.21,88.21,0,1,0-11.32,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg></span>
</button>
<input class="se__input" id="se-input" type="text" placeholder="What can we find for you?" aria-label="Search query" />
<span class="se__q" id="se-q" aria-hidden="true">spring dress</span>
</div>
<p class="se__log" id="se-log" aria-live="polite"></p>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 아이콘이 입력칸으로 펼쳐지는 검색 FAB — @property 로 등록한 --w 가 길이로 흐른다
$duration: 320ms;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
// 길이로 등록해야 중간값이 생긴다 — 등록 없으면 한 번에 튄다
@property --w {
syntax: "<length>";
inherits: false;
initial-value: 48px;
}
.se {
display: grid;
justify-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.se__hint {
margin: 0;
color: $subject-ink;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.se__dock { display: grid; gap: $sp-1; justify-items: center; }
.se__pill {
--w: 48px;
position: relative;
display: flex;
align-items: center;
box-sizing: border-box;
width: var(--w);
height: 48px;
padding: 0 $sp-1;
border: 1px solid rgba(23, 20, 26, .2);
border-radius: $r-pill;
background: $subject-cream;
box-shadow: $shadow-press;
transition: --w $duration $easing, border-color $dur-instant linear;
}
.se__go {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: $color;
color: $subject-cream;
font-size: 20px;
line-height: 1;
cursor: pointer;
transition: transform $dur-instant $ease-pop;
}
.se__go:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.se__go:active { transform: scale(.9); }
.se__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
height: 36px;
margin: 0 0 0 $sp-2;
padding: 0;
border: 0;
background: transparent;
color: $subject-ink;
font-family: inherit;
font-size: 13px;
line-height: 1.3;
opacity: 0;
pointer-events: none;
transition: opacity 0ms $duration;
}
.se__input:focus-visible { outline: none; }
.se__input::placeholder { color: rgba(23, 20, 26, .45); }
.se.is-open .se__input { opacity: 1; pointer-events: auto; }
.se.is-open .se__pill { border-color: $color; }
// 루프에서만 보이는 보기용 검색어 — 진짜 입력칸은 비어 있다
.se__q {
position: absolute;
left: 56px;
top: 50%;
margin: 0;
color: $subject-ink;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
transform: translateY(-50%);
opacity: 0;
pointer-events: none;
}
.se__log {
margin: 0;
min-height: 14px;
color: rgba(255, 247, 230, .72);
font-size: 11px;
line-height: 1.3;
}
// ── 격자 미리보기 전용 2초 루프 ──
@keyframes se-loop-w {
0%, 12% { --w: 48px; }
34%, 82% { --w: 264px; }
94%, 100% { --w: 48px; }
}
@keyframes se-loop-q {
0%, 30% { opacity: 0; }
31%, 81% { opacity: 1; }
82%, 100% { opacity: 0; }
}
@keyframes se-loop-hint {
0%, 11% { opacity: 1; }
12%, 81% { opacity: .4; }
82%, 100% { opacity: 1; }
}
.se.is-demo {
.se__pill {
animation-name: se-loop-w;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.se__q {
animation-name: se-loop-q;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.se__hint {
animation-name: se-loop-hint;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 루프 중에는 보기용 글자가 겹치지 않게 placeholder 를 감춘다
.se__input::placeholder { color: transparent; }
.se__input { opacity: 0; }
}
@media (max-width: 340px) {
.se__pill { height: 44px; }
.se__go { width: 36px; height: 36px; font-size: 18px; }
.se__input { height: 32px; font-size: 12px; }
.se__q { left: 52px; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.se.is-demo .se__pill,
.se.is-demo .se__q,
.se.is-demo .se__hint { animation: none !important; }
// 펼쳐진 결과만 남는다 — 입력칸이 열린 채 멈춘다
.se.is-demo .se__pill { --w: 264px !important; }
.se.is-demo .se__q { opacity: 1 !important; }
.se.is-demo .se__hint { opacity: .4 !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);
}
@property --w {
syntax: "<length>";
inherits: false;
initial-value: 48px;
}
.se {
display: grid;
justify-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.se__hint {
margin: 0;
color: #17141a;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.se__dock {
display: grid;
gap: 4px;
justify-items: center;
}
.se__pill {
--w: 48px;
position: relative;
display: flex;
align-items: center;
box-sizing: border-box;
width: var(--w);
height: 48px;
padding: 0 4px;
border: 1px solid rgba(23, 20, 26, 0.2);
border-radius: 999px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: --w 320ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 140ms linear;
}
.se__go {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: #2f6df6;
color: #fff7e6;
font-size: 20px;
line-height: 1;
cursor: pointer;
transition: transform 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.se__go:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.se__go:active {
transform: scale(0.9);
}
.se__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
height: 36px;
margin: 0 0 0 8px;
padding: 0;
border: 0;
background: transparent;
color: #17141a;
font-family: inherit;
font-size: 13px;
line-height: 1.3;
opacity: 0;
pointer-events: none;
transition: opacity 0ms 320ms;
}
.se__input:focus-visible {
outline: none;
}
.se__input::placeholder {
color: rgba(23, 20, 26, 0.45);
}
.se.is-open .se__input {
opacity: 1;
pointer-events: auto;
}
.se.is-open .se__pill {
border-color: #2f6df6;
}
.se__q {
position: absolute;
left: 56px;
top: 50%;
margin: 0;
color: #17141a;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
transform: translateY(-50%);
opacity: 0;
pointer-events: none;
}
.se__log {
margin: 0;
min-height: 14px;
color: rgba(255, 247, 230, 0.72);
font-size: 11px;
line-height: 1.3;
}
@keyframes se-loop-w {
0%, 12% {
--w: 48px;
}
34%, 82% {
--w: 264px;
}
94%, 100% {
--w: 48px;
}
}
@keyframes se-loop-q {
0%, 30% {
opacity: 0;
}
31%, 81% {
opacity: 1;
}
82%, 100% {
opacity: 0;
}
}
@keyframes se-loop-hint {
0%, 11% {
opacity: 1;
}
12%, 81% {
opacity: 0.4;
}
82%, 100% {
opacity: 1;
}
}
.se.is-demo .se__pill {
animation-name: se-loop-w;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.se.is-demo .se__q {
animation-name: se-loop-q;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.se.is-demo .se__hint {
animation-name: se-loop-hint;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.se.is-demo .se__input::placeholder {
color: transparent;
}
.se.is-demo .se__input {
opacity: 0;
}
@media (max-width: 340px) {
.se__pill {
height: 44px;
}
.se__go {
width: 36px;
height: 36px;
font-size: 18px;
}
.se__input {
height: 32px;
font-size: 12px;
}
.se__q {
left: 52px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.se.is-demo .se__pill,
.se.is-demo .se__q,
.se.is-demo .se__hint {
animation: none !important;
}
.se.is-demo .se__pill {
--w: 264px !important;
}
.se.is-demo .se__q {
opacity: 1 !important;
}
.se.is-demo .se__hint {
opacity: 0.4 !important;
}
}// 확장 검색 FAB: --w 를 직접 바꿔 알약이 입력칸으로 펼쳐진다 (요소 자신에 setProperty)
var se = document.getElementById('se');
var seGo = document.getElementById('se-go');
var seInput = document.getElementById('se-input');
var sePill = document.getElementById('se-pill');
var seHint = document.getElementById('se-hint');
var seLog = document.getElementById('se-log');
var seOpen = false;
function seSet(v) {
se.classList.remove('is-demo');
seOpen = v;
se.classList.toggle('is-open', v);
sePill.style.setProperty('--w', v ? '264px' : '48px');
seGo.setAttribute('aria-expanded', v ? 'true' : 'false');
seGo.setAttribute('aria-label', v ? 'Close search' : 'Open search');
seHint.textContent = v ? 'Type a search term' : 'Open search';
if (v) {
window.setTimeout(function () { seInput.focus({ preventScroll: true }); }, 320);
} else {
seLog.textContent = seInput.value.trim() ? 'Your search term is kept' : '';
seInput.blur();
}
}
seGo.addEventListener('click', function () { seSet(!seOpen); });
seInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') seSet(false);
if (e.key === 'Escape') { seInput.value = ''; seSet(false); }
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./SearchOpenFab.module.scss";
export type SearchOpenFabProps = {
closedHint?: string;
openHint?: string;
openLabel?: string;
closeLabel?: string;
placeholder?: string;
inputLabel?: string;
/** 격자 루프에서만 보이는 보기용 검색어. 진짜 입력칸은 비어 있다 */
loopQuery?: string;
keptLog?: string;
groupLabel?: string;
inputId?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--w"?: string;
};
// ambient 에 KeyboardEvent 가 없다 — 쓰는 필드만 로컬로 선언한다
type KeyEvt = { key: string };
const OPEN_W = "264px";
const SHUT_W = "48px";
function Glass() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path d="M192,112a80,80,0,1,1-80-80A80,80,0,0,1,192,112Z" opacity=".2" />
<path d="M229.66,218.34,179.6,168.28a88.21,88.21,0,1,0-11.32,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z" />
</svg>
);
}
export default function SearchOpenFab({
closedHint = "Open search",
openHint = "Type what you are looking for",
openLabel = "Open search",
closeLabel = "Close search",
placeholder = "What can we find for you?",
inputLabel = "Search term",
loopQuery = "spring dress",
keptLog = "Search term kept",
groupLabel = "Search FAB that expands into an input",
inputId = "se-input",
demo = true,
duration = 320,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SearchOpenFabProps) {
const [open, setOpen] = useState(false);
const [live, setLive] = useState(demo);
const [q, setQ] = useState("");
const [log, setLog] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const focusT = useRef<number>(null);
useEffect(() => () => {
if (focusT.current !== null) window.clearTimeout(focusT.current);
}, []);
function clearFocusT() {
if (focusT.current !== null) {
window.clearTimeout(focusT.current);
focusT.current = null;
}
}
// 열기: 알약이 다 펼쳐진 뒤에 커서를 넣어야 펼침이 끊기지 않는다
function openUp() {
setLive(false);
setOpen(true);
clearFocusT();
focusT.current = window.setTimeout(() => {
const el = inputRef.current;
if (el) el.focus({ preventScroll: true });
}, duration);
}
// 닫기: 남은 검색어가 있으면 그 사실만 알린다 (text 는 Escape 로 비운 값도 받는다)
function shut(text: string) {
setLive(false);
setOpen(false);
clearFocusT();
setLog(text.trim() ? keptLog : "");
const el = inputRef.current;
if (el) el.blur();
}
function toggle() {
if (open) shut(q);
else openUp();
}
function onKey(e: KeyEvt) {
if (e.key === "Enter") shut(q);
if (e.key === "Escape") {
setQ("");
shut("");
}
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 루프 중에는 --w 를 애니메이션이 굴린다 — 사람이 손댄 뒤에만 인라인으로 고정한다
const pillVars: Vars = live ? {} : { "--w": open ? OPEN_W : SHUT_W };
const cls = [styles.se, live ? styles.isDemo : "", open ? styles.isOpen : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<p className={styles.hint}>{open ? openHint : closedHint}</p>
<div className={styles.dock}>
<div className={styles.pill} style={pillVars}>
<button
className={styles.go}
type="button"
aria-expanded={open}
aria-controls={inputId}
aria-label={open ? closeLabel : openLabel}
onClick={toggle}
>
<span aria-hidden="true"><Glass /></span>
</button>
<input
className={styles.input}
id={inputId}
ref={inputRef}
type="text"
value={q}
placeholder={placeholder}
aria-label={inputLabel}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
onKeyDown={onKey}
/>
<span className={styles.q} aria-hidden="true">{loopQuery}</span>
</div>
<p className={styles.log} aria-live="polite">{log}</p>
</div>
</section>
);
}07 · Search FAB that unfolds into an input — Measured area 10.802% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Escape empties the field and folds the pill back to a circle.
@property --w {
syntax: "<length>";
inherits: false;
initial-value: 48px;
}
.se__pill {
--w: 48px;
box-sizing: border-box;
width: var(--w);
height: 48px;
border-radius: 999px;
transition: --w 320ms cubic-bezier(0.2, 0.8, 0.2, 1),
border-color 140ms linear;
}
08Help button that opens a chat launcher
What happens on screen
A contact link that navigates away costs the reader the screen they were standing on. Here, a 148px panel lifts into place above the FAB with translateY, the line you send becomes a real bubble in the log, and the spot where the answer arrives is announced through aria-live.
<main class="stage">
<section class="ch is-demo" id="ch" aria-label="A help button that opens a chat launcher">
<div class="ch__panel" id="ch-panel" role="dialog" aria-label="Help chat" aria-modal="false">
<header class="ch__head">
<span class="ch__name">How can we help?</span>
</header>
<div class="ch__log" id="ch-log" aria-live="polite">
<p class="ch__bubble ch__bubble--bot">Write what you wonder. A person answers on weekday days.</p>
<p class="ch__bubble ch__loop-me" aria-hidden="true">When does it ship?</p>
<p class="ch__bubble ch__loop-dots" aria-hidden="true">...</p>
<p class="ch__bubble ch__loop-reply" aria-hidden="true">It leaves first thing tomorrow morning.</p>
</div>
<form class="ch__form" id="ch-form">
<input class="ch__input" id="ch-input" type="text" placeholder="Type a message" aria-label="Message input" />
<button class="ch__send" type="submit" aria-label="Send the message">
<span aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M227.91,134.86,59.93,231a8,8,0,0,1-11.44-9.67L80,128,48.49,34.72a8,8,0,0,1,11.44-9.67l168,95.85A8,8,0,0,1,227.91,134.86Z" opacity=".2"/><path d="M231.87,114l-168-95.89A16,16,0,0,0,40.92,37.34L71.55,128,40.92,218.67A16,16,0,0,0,56,240a16.15,16.15,0,0,0,7.93-2.1l167.92-96.05a16,16,0,0,0,.05-27.89ZM56,224a.56.56,0,0,0,0-.12L85.74,136H144a8,8,0,0,0,0-16H85.74L56.06,32.16A.46.46,0,0,0,56,32l168,95.83Z"/></svg></span>
</button>
</form>
</div>
<button class="ch__fab" type="button" id="ch-fab" aria-expanded="false" aria-controls="ch-panel" aria-label="Open the help chat">
<span aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity=".2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg></span>
</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 도움말 FAB 과 채팅 창 — 판은 translateY 로 열리고 대화는 실제로 쌓인다
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-orange;
@include stage($stage-paper);
.ch {
position: relative;
display: grid;
justify-items: end;
align-content: end;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 판: 절대배치 대신 자리를 확보해 320px 넘침을 막는다 (보임만 변한다)
.ch__panel {
box-sizing: border-box;
width: min(236px, 92vw);
height: 148px;
display: grid;
grid-template-rows: auto 1fr auto;
padding: $sp-2;
border: 1px solid rgba(23, 20, 26, .14);
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-press;
transform: translateY(16px) scale(.96);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $dur-instant linear;
}
.ch__head {
display: flex;
align-items: center;
margin-bottom: $sp-1;
}
.ch__name { font-size: 11px; font-weight: 700; line-height: 1.3; }
.ch__log {
display: grid;
align-content: start;
gap: $sp-1;
overflow: hidden;
}
.ch__bubble {
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-control;
font-size: 11px;
line-height: 1.45;
}
.ch__bubble--bot {
justify-self: start;
background: rgba(23, 20, 26, .07);
}
.ch__bubble--me {
justify-self: end;
background: $stage-orange;
color: $subject-cream;
}
.ch__form {
display: flex;
gap: $sp-1;
margin-top: $sp-1;
}
.ch__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
height: 32px;
margin: 0;
padding: 0 $sp-2;
border: 1px solid rgba(23, 20, 26, .22);
border-radius: $r-pill;
background: transparent;
color: $subject-ink;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.ch__input::placeholder { color: rgba(23, 20, 26, .45); }
.ch__input:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
.ch__send {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: $stage-orange;
color: $subject-cream;
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.ch__send:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.ch__fab {
display: inline-grid;
place-items: center;
width: 48px;
height: 48px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: $subject-ink;
color: $stage-orange;
font-size: 24px;
line-height: 1;
cursor: pointer;
box-shadow: $shadow-raised;
transition: transform $dur-instant $ease-pop;
}
.ch__fab:focus-visible { outline: 3px solid $subject-cream; outline-offset: 3px; }
.ch__fab:active { transform: scale(.92); }
// 정적 경로 — 열리면 판이 제자리에 살아난다
.ch.is-open .ch__panel {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
// ── 격자 미리보기 전용 2초 루프: 열림 → 보낸 말 → 답 (닫힘은 재현하지 않는다) ──
@keyframes ch-loop-panel {
0%, 9% { transform: translateY(16px) scale(.96); opacity: 0; }
10% { opacity: 1; }
28%, 100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes ch-loop-me {
0%, 42% { transform: translateY(10px); opacity: 0; }
43% { opacity: 1; }
56%, 100% { transform: translateY(0); opacity: 1; }
}
@keyframes ch-loop-dots {
0%, 47% { transform: translateY(10px); opacity: 0; }
48% { opacity: 1; }
60%, 68% { transform: translateY(0); opacity: 1; }
69%, 100% { transform: translateY(0); opacity: 0; }
}
@keyframes ch-loop-reply {
0%, 68% { transform: translateY(10px); opacity: 0; }
69% { opacity: 1; }
80%, 100% { transform: translateY(0); opacity: 1; }
}
.ch__loop-me,
.ch__loop-dots,
.ch__loop-reply {
justify-self: end;
display: none;
}
.ch__loop-me {
background: $stage-orange;
color: $subject-cream;
}
.ch__loop-dots,
.ch__loop-reply {
justify-self: start;
background: rgba(23, 20, 26, .07);
}
.ch.is-demo {
.ch__panel {
animation-name: ch-loop-panel;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ch__loop-me {
display: block;
animation-name: ch-loop-me;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ch__loop-dots {
display: block;
animation-name: ch-loop-dots;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ch__loop-reply {
display: block;
animation-name: ch-loop-reply;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
}
@media (max-width: 340px) {
.ch__panel { height: 112px; padding: $sp-1; }
.ch__bubble { font-size: 10px; }
.ch__input, .ch__send { height: 28px; }
.ch__send { width: 28px; font-size: 14px; }
.ch__fab { width: 40px; height: 40px; font-size: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.ch.is-demo .ch__panel,
.ch.is-demo .ch__loop-me,
.ch.is-demo .ch__loop-dots,
.ch.is-demo .ch__loop-reply { animation: none !important; transform: none !important; }
// 열리고 답까지 온 결과만 남긴다 — 채팅이 꽉 찬 모습으로 멈춘다
.ch.is-demo .ch__panel { opacity: 1 !important; pointer-events: auto !important; }
.ch.is-demo .ch__loop-me,
.ch.is-demo .ch__loop-reply { opacity: 1 !important; display: block !important; }
.ch.is-demo .ch__loop-dots { opacity: 0 !important; display: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ch {
position: relative;
display: grid;
justify-items: end;
align-content: end;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ch__panel {
box-sizing: border-box;
width: min(236px, 92vw);
height: 148px;
display: grid;
grid-template-rows: auto 1fr auto;
padding: 8px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(16px) scale(0.96);
opacity: 0;
pointer-events: none;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 140ms linear;
}
.ch__head {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.ch__name {
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.ch__log {
display: grid;
align-content: start;
gap: 4px;
overflow: hidden;
}
.ch__bubble {
margin: 0;
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
line-height: 1.45;
}
.ch__bubble--bot {
justify-self: start;
background: rgba(23, 20, 26, 0.07);
}
.ch__bubble--me {
justify-self: end;
background: #ff4d1f;
color: #fff7e6;
}
.ch__form {
display: flex;
gap: 4px;
margin-top: 4px;
}
.ch__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
height: 32px;
margin: 0;
padding: 0 8px;
border: 1px solid rgba(23, 20, 26, 0.22);
border-radius: 999px;
background: transparent;
color: #17141a;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.ch__input::placeholder {
color: rgba(23, 20, 26, 0.45);
}
.ch__input:focus-visible {
outline: 2px solid #ff4d1f;
outline-offset: 1px;
}
.ch__send {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: #ff4d1f;
color: #fff7e6;
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.ch__send:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ch__fab {
display: inline-grid;
place-items: center;
width: 48px;
height: 48px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: #17141a;
color: #ff4d1f;
font-size: 24px;
line-height: 1;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: transform 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ch__fab:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.ch__fab:active {
transform: scale(0.92);
}
.ch.is-open .ch__panel {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
@keyframes ch-loop-panel {
0%, 9% {
transform: translateY(16px) scale(0.96);
opacity: 0;
}
10% {
opacity: 1;
}
28%, 100% {
transform: translateY(0) scale(1);
opacity: 1;
}
}
@keyframes ch-loop-me {
0%, 42% {
transform: translateY(10px);
opacity: 0;
}
43% {
opacity: 1;
}
56%, 100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes ch-loop-dots {
0%, 47% {
transform: translateY(10px);
opacity: 0;
}
48% {
opacity: 1;
}
60%, 68% {
transform: translateY(0);
opacity: 1;
}
69%, 100% {
transform: translateY(0);
opacity: 0;
}
}
@keyframes ch-loop-reply {
0%, 68% {
transform: translateY(10px);
opacity: 0;
}
69% {
opacity: 1;
}
80%, 100% {
transform: translateY(0);
opacity: 1;
}
}
.ch__loop-me,
.ch__loop-dots,
.ch__loop-reply {
justify-self: end;
display: none;
}
.ch__loop-me {
background: #ff4d1f;
color: #fff7e6;
}
.ch__loop-dots,
.ch__loop-reply {
justify-self: start;
background: rgba(23, 20, 26, 0.07);
}
.ch.is-demo .ch__panel {
animation-name: ch-loop-panel;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ch.is-demo .ch__loop-me {
display: block;
animation-name: ch-loop-me;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ch.is-demo .ch__loop-dots {
display: block;
animation-name: ch-loop-dots;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ch.is-demo .ch__loop-reply {
display: block;
animation-name: ch-loop-reply;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@media (max-width: 340px) {
.ch__panel {
height: 112px;
padding: 4px;
}
.ch__bubble {
font-size: 10px;
}
.ch__input, .ch__send {
height: 28px;
}
.ch__send {
width: 28px;
font-size: 14px;
}
.ch__fab {
width: 40px;
height: 40px;
font-size: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.ch.is-demo .ch__panel,
.ch.is-demo .ch__loop-me,
.ch.is-demo .ch__loop-dots,
.ch.is-demo .ch__loop-reply {
animation: none !important;
transform: none !important;
}
.ch.is-demo .ch__panel {
opacity: 1 !important;
pointer-events: auto !important;
}
.ch.is-demo .ch__loop-me,
.ch.is-demo .ch__loop-reply {
opacity: 1 !important;
display: block !important;
}
.ch.is-demo .ch__loop-dots {
opacity: 0 !important;
display: none !important;
}
}// 도움말 FAB: 채팅 창이 열리고 보낸 말은 진짜로 쌓인다
var ch = document.getElementById('ch');
var chFab = document.getElementById('ch-fab');
var chForm = document.getElementById('ch-form');
var chInput = document.getElementById('ch-input');
var chLog = document.getElementById('ch-log');
function chAdd(text, who) {
var p = document.createElement('p');
p.className = 'ch__bubble ch__bubble--' + who;
p.textContent = text;
chLog.appendChild(p);
return p;
}
chFab.addEventListener('click', function () {
ch.classList.remove('is-demo');
ch.classList.toggle('is-open');
var open = ch.classList.contains('is-open');
chFab.setAttribute('aria-expanded', open ? 'true' : 'false');
chFab.setAttribute('aria-label', open ? 'Close the help chat' : 'Open the help chat');
if (open) window.setTimeout(function () { chInput.focus({ preventScroll: true }); }, 240);
});
chForm.addEventListener('submit', function (e) {
e.preventDefault();
var text = chInput.value.trim();
if (!text) return;
ch.classList.remove('is-demo');
chAdd(text, 'me');
chInput.value = '';
var dots = chAdd('...', 'bot');
window.setTimeout(function () {
dots.textContent = 'Got it. One moment, please.';
}, 900);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./ChatHelpFab.module.scss";
export type ChatHelpFabProps = {
panelLabel?: string;
headName?: string;
greeting?: string;
placeholder?: string;
inputLabel?: string;
sendLabel?: string;
openLabel?: string;
closeLabel?: string;
groupLabel?: string;
/** 격자 루프에서만 보이는 보기용 대화 */
loopAsk?: string;
loopDots?: string;
loopReply?: string;
/** 사람이 보낸 말에 붙는 자동 응답 */
replyText?: string;
replyDelay?: number;
panelId?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 에 FormEvent 가 없다 — 쓰는 것만 로컬로 선언한다
type SubmitEvt = { preventDefault(): void };
type Line = { id: number; text: string; me: boolean };
function Send() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path d="M227.91,134.86,59.93,231a8,8,0,0,1-11.44-9.67L80,128,48.49,34.72a8,8,0,0,1,11.44-9.67l168,95.85A8,8,0,0,1,227.91,134.86Z" opacity=".2" />
<path d="M231.87,114l-168-95.89A16,16,0,0,0,40.92,37.34L71.55,128,40.92,218.67A16,16,0,0,0,56,240a16.15,16.15,0,0,0,7.93-2.1l167.92-96.05a16,16,0,0,0,.05-27.89ZM56,224a.56.56,0,0,0,0-.12L85.74,136H144a8,8,0,0,0,0-16H85.74L56.06,32.16A.46.46,0,0,0,56,32l168,95.83Z" />
</svg>
);
}
function Chat() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity=".2" />
<path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z" />
</svg>
);
}
export default function ChatHelpFab({
panelLabel = "Help chat",
headName = "How can we help?",
greeting = "Tell us what you need. A human answers on weekday afternoons.",
placeholder = "Write a message",
inputLabel = "Message",
sendLabel = "Send message",
openLabel = "Open help chat",
closeLabel = "Close help chat",
groupLabel = "Help button that opens a chat panel",
loopAsk = "When does it ship?",
loopDots = "...",
loopReply = "It leaves first thing tomorrow morning.",
replyText = "Got it. Hang on a moment.",
replyDelay = 900,
panelId = "ch-panel",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ff4d1f",
}: ChatHelpFabProps) {
const [open, setOpen] = useState(false);
const [live, setLive] = useState(demo);
const [draft, setDraft] = useState("");
const [lines, setLines] = useState<Line[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const focusT = useRef<number>(null);
const replyT = useRef<number>(null);
const seq = useRef<number>(0);
useEffect(() => () => {
if (focusT.current !== null) window.clearTimeout(focusT.current);
if (replyT.current !== null) window.clearTimeout(replyT.current);
}, []);
function toggle() {
setLive(false);
const next = !open;
setOpen(next);
if (focusT.current !== null) window.clearTimeout(focusT.current);
if (!next) return;
// 판이 다 올라온 뒤에 커서를 넣는다
focusT.current = window.setTimeout(() => {
const el = inputRef.current;
if (el) el.focus({ preventScroll: true });
}, duration);
}
// 보낸 말은 진짜로 쌓인다 — 먼저 "..." 이 붙고 잠시 뒤 그 말풍선이 답으로 바뀐다
function send(e: SubmitEvt) {
e.preventDefault();
const text = draft.trim();
if (!text) return;
setLive(false);
const mineId = (seq.current === null ? 0 : seq.current) + 1;
const botId = mineId + 1;
seq.current = botId;
setLines((prev) => prev.concat([
{ id: mineId, text: text, me: true },
{ id: botId, text: loopDots, me: false },
]));
setDraft("");
if (replyT.current !== null) window.clearTimeout(replyT.current);
replyT.current = window.setTimeout(() => {
setLines((prev) => prev.map((l) => (l.id === botId ? { id: l.id, text: replyText, me: false } : l)));
}, replyDelay);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.ch, live ? styles.isDemo : "", open ? styles.isOpen : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={styles.panel} id={panelId} role="dialog" aria-label={panelLabel} aria-modal="false">
<header className={styles.head}>
<span className={styles.name}>{headName}</span>
</header>
<div className={styles.log} aria-live="polite">
<p className={[styles.bubble, styles.bubbleBot].join(" ")}>{greeting}</p>
<p className={[styles.bubble, styles.loopMe].join(" ")} aria-hidden="true">{loopAsk}</p>
<p className={[styles.bubble, styles.loopDots].join(" ")} aria-hidden="true">{loopDots}</p>
<p className={[styles.bubble, styles.loopReply].join(" ")} aria-hidden="true">{loopReply}</p>
{lines.map((l) => (
<p key={l.id} className={[styles.bubble, l.me ? styles.bubbleMe : styles.bubbleBot].join(" ")}>{l.text}</p>
))}
</div>
<form className={styles.form} onSubmit={send}>
<input
className={styles.input}
ref={inputRef}
type="text"
value={draft}
placeholder={placeholder}
aria-label={inputLabel}
onChange={(e: ChangeEvent<HTMLInputElement>) => setDraft(e.target.value)}
/>
<button className={styles.send} type="submit" aria-label={sendLabel}>
<span aria-hidden="true"><Send /></span>
</button>
</form>
</div>
<button
className={styles.fab}
type="button"
aria-expanded={open}
aria-controls={panelId}
aria-label={open ? closeLabel : openLabel}
onClick={toggle}
>
<span aria-hidden="true"><Chat /></span>
</button>
</section>
);
}08 · Help button that opens a chat launcher — Measured area 27.706% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
It is the biggest arrival in this set, changing 26.31% of the stage in one frame.
function chAdd(text, who) {
var p = document.createElement('p');
p.className = 'ch__bubble ch__bubble--' + who;
p.textContent = text;
chLog.appendChild(p);
return p;
}
chFab.addEventListener('click', function () {
ch.classList.toggle('is-open');
var open = ch.classList.contains('is-open');
chFab.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) window.setTimeout(function () {
chInput.focus({ preventScroll: true });
}, 240);
});
09FAB that follows your long-press drag
What moves
Sometimes the floating button parks itself on the exact sentence you wanted to read. A press held past 350ms flips it into grab mode, setPointerCapture keeps the move events coming even when the finger slides off the circle, and the button rides translateX under it.
<main class="stage">
<section class="dg is-demo" id="dg" aria-label="A FAB that moves when you long-press and drag">
<div class="dg__screen" id="dg-screen">
<span class="dg__guide dg__guide--l" aria-hidden="true"></span>
<span class="dg__guide dg__guide--r" aria-hidden="true"></span>
<p class="dg__read" id="dg-read">Snapped to the right</p>
<button class="dg__fab" type="button" id="dg-fab" aria-label="Move the help button">
<span aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M224,48V208a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48Z" opacity=".2"/><path d="M72,60A12,12,0,1,1,60,48,12,12,0,0,1,72,60Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,128,48Zm68,24a12,12,0,1,0-12-12A12,12,0,0,0,196,72ZM60,116a12,12,0,1,0,12,12A12,12,0,0,0,60,116Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,128,116Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,196,116ZM60,184a12,12,0,1,0,12,12A12,12,0,0,0,60,184Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,128,184Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,196,184Z"/></svg></span>
</button>
</div>
<p class="dg__hint" id="dg-hint">Hold longer than 350ms, then drag</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 길게 눌러 끌면 따라오는 FAB — 버튼 자체가 translate 로 움직이고 놓으면 가장자리로 스냅
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
.dg {
display: grid;
justify-items: center;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dg__screen {
position: relative;
box-sizing: border-box;
width: min(280px, 92vw);
height: 108px;
border: 1px solid rgba(255, 247, 230, .22);
border-radius: $r-card;
background: rgba(255, 247, 230, .05);
overflow: hidden;
}
// 스냅 가이드: 붙잡는 동안에만 살아난다
.dg__guide {
position: absolute;
top: $sp-2;
bottom: $sp-2;
width: 0;
border-left: 2px dashed rgba(255, 247, 230, .3);
opacity: 0;
transition: opacity $dur-instant linear;
}
.dg__guide--l { left: $sp-2; }
.dg__guide--r { right: $sp-2; }
.dg__read {
position: absolute;
left: 0;
right: 0;
top: $sp-2;
margin: 0;
color: rgba(255, 247, 230, .62);
font-size: 11px;
font-weight: 700;
line-height: 1.3;
text-align: center;
}
.dg__fab {
position: absolute;
left: $sp-2;
bottom: $sp-2;
display: inline-grid;
place-items: center;
width: 52px;
height: 52px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: $color;
color: $stage-ink;
font-size: 26px;
line-height: 1;
cursor: grab;
transform: translateX(212px);
transition: transform $duration $easing, box-shadow $dur-instant linear;
touch-action: none;
user-select: none;
}
.dg__fab:focus-visible { outline: 3px solid $subject-cream; outline-offset: 3px; }
// 붙잡기 상태 — 끌기 중에는 스냅 전환을 끊어 즉시 따라오게 한다
.dg__fab.is-grab {
cursor: grabbing;
transition: box-shadow $dur-instant linear;
box-shadow: 0 0 0 4px rgba(76, 212, 166, .25), $shadow-raised;
}
.dg__screen.is-holding .dg__guide { opacity: 1; }
.dg__hint {
margin: 0;
color: rgba(255, 247, 230, .62);
font-size: 11px;
font-weight: 700;
line-height: 1.3;
text-align: center;
}
// ── 격자 미리보기 전용 2초 루프: 오른쪽 → 붙잡음 → 왼쪽 → 놓음 → 오른쪽 ──
@keyframes dg-loop-move {
0%, 8% { transform: translateX(212px); }
12% { transform: translateX(212px) scale(1.12); }
42% { transform: translateX(0px) scale(1.12); }
46%, 52% { transform: translateX(0px) scale(1); }
58% { transform: translateX(0px) scale(1.12); }
86% { transform: translateX(212px) scale(1.12); }
90%, 100% { transform: translateX(212px) scale(1); }
}
@keyframes dg-loop-guide-l {
0%, 11% { opacity: 0; }
12%, 44% { opacity: 1; }
46%, 100% { opacity: 0; }
}
@keyframes dg-loop-guide-r {
0%, 11% { opacity: 0; }
12%, 88% { opacity: 1; }
90%, 100% { opacity: 0; }
}
@keyframes dg-loop-read {
0%, 11% { opacity: 1; }
12%, 43% { opacity: .3; }
44%, 57% { opacity: 1; }
58%, 87% { opacity: .3; }
88%, 100% { opacity: 1; }
}
.dg.is-demo {
.dg__fab {
animation-name: dg-loop-move;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.dg__guide--l {
animation-name: dg-loop-guide-l;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dg__guide--r {
animation-name: dg-loop-guide-r;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dg__read {
animation-name: dg-loop-read;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.dg__screen { height: 96px; }
.dg__fab { width: 44px; height: 44px; font-size: 22px; }
.dg__read { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.dg.is-demo .dg__fab,
.dg.is-demo .dg__guide--l,
.dg.is-demo .dg__guide--r,
.dg.is-demo .dg__read { animation: none !important; transform: none !important; }
// 붙잡힌 순간의 결과만 남긴다 — 큰 상태로 왼쪽에 멈춘다
.dg.is-demo .dg__fab { transform: translateX(0px) scale(1.12) !important; }
.dg.is-demo .dg__guide--l { opacity: 1 !important; }
.dg.is-demo .dg__guide--r { opacity: 0 !important; }
.dg.is-demo .dg__read { opacity: 1 !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);
}
.dg {
display: grid;
justify-items: center;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dg__screen {
position: relative;
box-sizing: border-box;
width: min(280px, 92vw);
height: 108px;
border: 1px solid rgba(255, 247, 230, 0.22);
border-radius: 16px;
background: rgba(255, 247, 230, 0.05);
overflow: hidden;
}
.dg__guide {
position: absolute;
top: 8px;
bottom: 8px;
width: 0;
border-left: 2px dashed rgba(255, 247, 230, 0.3);
opacity: 0;
transition: opacity 140ms linear;
}
.dg__guide--l {
left: 8px;
}
.dg__guide--r {
right: 8px;
}
.dg__read {
position: absolute;
left: 0;
right: 0;
top: 8px;
margin: 0;
color: rgba(255, 247, 230, 0.62);
font-size: 11px;
font-weight: 700;
line-height: 1.3;
text-align: center;
}
.dg__fab {
position: absolute;
left: 8px;
bottom: 8px;
display: inline-grid;
place-items: center;
width: 52px;
height: 52px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: #4cd4a6;
color: #17141a;
font-size: 26px;
line-height: 1;
cursor: grab;
transform: translateX(212px);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 140ms linear;
touch-action: none;
user-select: none;
}
.dg__fab:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.dg__fab.is-grab {
cursor: grabbing;
transition: box-shadow 140ms linear;
box-shadow: 0 0 0 4px rgba(76, 212, 166, 0.25), 0 14px 24px rgba(23, 20, 26, 0.32);
}
.dg__screen.is-holding .dg__guide {
opacity: 1;
}
.dg__hint {
margin: 0;
color: rgba(255, 247, 230, 0.62);
font-size: 11px;
font-weight: 700;
line-height: 1.3;
text-align: center;
}
@keyframes dg-loop-move {
0%, 8% {
transform: translateX(212px);
}
12% {
transform: translateX(212px) scale(1.12);
}
42% {
transform: translateX(0px) scale(1.12);
}
46%, 52% {
transform: translateX(0px) scale(1);
}
58% {
transform: translateX(0px) scale(1.12);
}
86% {
transform: translateX(212px) scale(1.12);
}
90%, 100% {
transform: translateX(212px) scale(1);
}
}
@keyframes dg-loop-guide-l {
0%, 11% {
opacity: 0;
}
12%, 44% {
opacity: 1;
}
46%, 100% {
opacity: 0;
}
}
@keyframes dg-loop-guide-r {
0%, 11% {
opacity: 0;
}
12%, 88% {
opacity: 1;
}
90%, 100% {
opacity: 0;
}
}
@keyframes dg-loop-read {
0%, 11% {
opacity: 1;
}
12%, 43% {
opacity: 0.3;
}
44%, 57% {
opacity: 1;
}
58%, 87% {
opacity: 0.3;
}
88%, 100% {
opacity: 1;
}
}
.dg.is-demo .dg__fab {
animation-name: dg-loop-move;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.dg.is-demo .dg__guide--l {
animation-name: dg-loop-guide-l;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dg.is-demo .dg__guide--r {
animation-name: dg-loop-guide-r;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dg.is-demo .dg__read {
animation-name: dg-loop-read;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.dg__screen {
height: 96px;
}
.dg__fab {
width: 44px;
height: 44px;
font-size: 22px;
}
.dg__read {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.dg.is-demo .dg__fab,
.dg.is-demo .dg__guide--l,
.dg.is-demo .dg__guide--r,
.dg.is-demo .dg__read {
animation: none !important;
transform: none !important;
}
.dg.is-demo .dg__fab {
transform: translateX(0px) scale(1.12) !important;
}
.dg.is-demo .dg__guide--l {
opacity: 1 !important;
}
.dg.is-demo .dg__guide--r {
opacity: 0 !important;
}
.dg.is-demo .dg__read {
opacity: 1 !important;
}
}// 제스처 FAB: 길게 누르면 붙잡고, 끌면 버튼 자체가 움직이며, 놓으면 가까운 가장자리로 스냅된다
var dg = document.getElementById('dg');
var dgFab = document.getElementById('dg-fab');
var dgScreen = document.getElementById('dg-screen');
var dgRead = document.getElementById('dg-read');
var dgHold = null; // 길게 누름 타이머
var dgGrab = false; // 붙잡기 상태
function dgSide(x) {
var box = dgScreen.getBoundingClientRect();
return x < box.left + box.width / 2 ? 'left' : 'right';
}
function dgSnap(x) {
var side = dgSide(x);
var box = dgScreen.getBoundingClientRect();
var maxX = box.width - dgFab.offsetWidth - 8;
dgFab.style.transform = 'translateX(' + (side === 'left' ? 0 : maxX) + 'px)';
dgRead.textContent = side === 'left' ? 'Snapped to the left' : 'Snapped to the right';
}
function dgClearHold() {
window.clearTimeout(dgHold);
dgHold = null;
}
dgFab.addEventListener('pointerdown', function (e) {
dg.classList.remove('is-demo');
dgHold = window.setTimeout(function () {
dgGrab = true;
dgFab.classList.add('is-grab');
dgScreen.classList.add('is-holding');
try { dgFab.setPointerCapture(e.pointerId); } catch (err) { }
dgRead.textContent = 'Held · release to snap';
}, 350);
});
dgFab.addEventListener('pointermove', function (e) {
if (!dgGrab) return;
var box = dgScreen.getBoundingClientRect();
var maxX = box.width - dgFab.offsetWidth - 8;
var x = Math.min(Math.max(e.clientX - box.left - dgFab.offsetWidth / 2, 0), maxX);
dgFab.style.transform = 'translateX(' + x + 'px)';
});
dgFab.addEventListener('pointerup', function (e) {
dgClearHold();
dgScreen.classList.remove('is-holding');
if (!dgGrab) return;
dgGrab = false;
dgFab.classList.remove('is-grab');
dgSnap(e.clientX);
});
dgFab.addEventListener('keydown', function (e) {
dg.classList.remove('is-demo');
var box = dgScreen.getBoundingClientRect();
var maxX = box.width - dgFab.offsetWidth - 8;
if (e.key === 'ArrowLeft') dgFab.style.transform = 'translateX(0px)';
if (e.key === 'ArrowRight') dgFab.style.transform = 'translateX(' + maxX + 'px)';
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./DragMoveFab.module.scss";
export type DragMoveFabProps = {
fabLabel?: string;
leftRead?: string;
rightRead?: string;
grabbedRead?: string;
hint?: string;
groupLabel?: string;
/** 붙잡기로 넘어가는 길게 누름 시간(ms) */
holdMs?: number;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 에 KeyboardEvent 가 없다 — 쓰는 필드만 로컬로 선언한다
type KeyEvt = { key: string; currentTarget: HTMLButtonElement };
type Box = { left: number; width: number; maxX: number };
/** 화면 안쪽 여백 — 스냅 자리는 양 끝에서 이만큼 떨어진다 */
const EDGE = 8;
function Dots() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path d="M224,48V208a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48Z" opacity=".2" />
<path d="M72,60A12,12,0,1,1,60,48,12,12,0,0,1,72,60Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,128,48Zm68,24a12,12,0,1,0-12-12A12,12,0,0,0,196,72ZM60,116a12,12,0,1,0,12,12A12,12,0,0,0,60,116Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,128,116Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,196,116ZM60,184a12,12,0,1,0,12,12A12,12,0,0,0,60,184Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,128,184Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,196,184Z" />
</svg>
);
}
export default function DragMoveFab({
fabLabel = "Move the help button",
leftRead = "Docked on the left",
rightRead = "Docked on the right",
grabbedRead = "Grabbed · release to snap",
hint = "Press for longer than 350ms, then drag",
groupLabel = "FAB you press and hold to drag around",
holdMs = 350,
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: DragMoveFabProps) {
const [tx, setTx] = useState<number | null>(null); // null 이면 CSS 기본 자리(오른쪽)
const [grab, setGrab] = useState(false);
const [holding, setHolding] = useState(false);
const [live, setLive] = useState(demo);
const [read, setRead] = useState(rightRead);
const screenRef = useRef<HTMLDivElement>(null);
const holdT = useRef<number>(null);
useEffect(() => () => {
if (holdT.current !== null) window.clearTimeout(holdT.current);
}, []);
function clearHold() {
if (holdT.current !== null) {
window.clearTimeout(holdT.current);
holdT.current = null;
}
}
function bounds(fab: HTMLButtonElement): Box | null {
const screen = screenRef.current;
if (!screen) return null;
const box = screen.getBoundingClientRect();
return { left: box.left, width: box.width, maxX: box.width - fab.offsetWidth - EDGE };
}
// 길게 누름이 채워져야 붙잡기로 넘어간다 — 짧은 누름은 그냥 클릭이다
function down(e: PointerEvent<HTMLButtonElement>) {
setLive(false);
const fab = e.currentTarget;
const pid: number = e.pointerId;
clearHold();
holdT.current = window.setTimeout(() => {
setGrab(true);
setHolding(true);
try { fab.setPointerCapture(pid); } catch { /* 캡처를 못 해도 끌기는 된다 */ }
setRead(grabbedRead);
}, holdMs);
}
function move(e: PointerEvent<HTMLButtonElement>) {
if (!grab) return;
const fab = e.currentTarget;
const b = bounds(fab);
if (!b) return;
setTx(Math.min(Math.max(e.clientX - b.left - fab.offsetWidth / 2, 0), b.maxX));
}
// 놓으면 가까운 가장자리로 스냅된다
function up(e: PointerEvent<HTMLButtonElement>) {
clearHold();
setHolding(false);
if (!grab) return;
setGrab(false);
const b = bounds(e.currentTarget);
if (!b) return;
const left = e.clientX < b.left + b.width / 2;
setTx(left ? 0 : b.maxX);
setRead(left ? leftRead : rightRead);
}
// 키보드 경로 — vanilla 판은 transform 만 바꾸지만 읽어주는 문구도 같이 맞춘다
function key(e: KeyEvt) {
setLive(false);
const b = bounds(e.currentTarget);
if (!b) return;
if (e.key === "ArrowLeft") { setTx(0); setRead(leftRead); }
if (e.key === "ArrowRight") { setTx(b.maxX); setRead(rightRead); }
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const fabStyle: CSSProperties = tx === null ? {} : { transform: `translateX(${tx}px)` };
const cls = [styles.dg, live ? styles.isDemo : ""].filter(Boolean).join(" ");
const screenCls = [styles.screen, holding ? styles.isHolding : ""].filter(Boolean).join(" ");
const fabCls = [styles.fab, grab ? styles.isGrab : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={screenCls} ref={screenRef}>
<span className={[styles.guide, styles.guideL].join(" ")} aria-hidden="true" />
<span className={[styles.guide, styles.guideR].join(" ")} aria-hidden="true" />
<p className={styles.read} aria-live="polite">{read}</p>
<button
className={fabCls}
style={fabStyle}
type="button"
aria-label={fabLabel}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onKeyDown={key}
>
<span aria-hidden="true"><Dots /></span>
</button>
</div>
<p className={styles.hint}>{hint}</p>
</section>
);
}09 · FAB that follows your long-press drag — Measured area 10.568% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Releasing measures which half of the stage you are on and snaps to that edge, while a press that never reaches 350ms stays an ordinary click.
function dgSnap(x) {
var side = dgSide(x);
var box = dgScreen.getBoundingClientRect();
var maxX = box.width - dgFab.offsetWidth - 8;
dgFab.style.transform = 'translateX(' + (side === 'left' ? 0 : maxX) + 'px)';
}
dgFab.addEventListener('pointerdown', function (e) {
dgHold = window.setTimeout(function () {
dgGrab = true;
dgFab.classList.add('is-grab');
dgScreen.classList.add('is-holding');
try { dgFab.setPointerCapture(e.pointerId); } catch (err) { }
}, 350);
});
dgFab.addEventListener('pointerup', function (e) {
dgClearHold();
dgScreen.classList.remove('is-holding');
if (!dgGrab) return;
dgGrab = false;
dgFab.classList.remove('is-grab');
dgSnap(e.clientX);
});
Where it breaks — the trap
The break that shows up first is a control with no name at all. Six of the nine hold nothing but a glyph, and a glyph with no accessible name is read out as "button" and nothing else. Every one of them therefore got an aria-label, and the ones that toggle rewrite that label to match the state: the speed dial says "Open the create menu" while shut and "Close the create menu" while open. The theme button in 05 follows the same rule from the other side — its name describes what the next press will produce, not what you are currently looking at.
The second is a fade standing where a cut belongs. The first build of 05 cross-faded the sun into the moon, and the 24-frame poster pass grabbed the one frame where both were half visible. Cutting visibility into a single frame killed the ghost and immediately created a second problem, because a pure cut leaves only two or three frames with anything in them. Widening the disc's 180-degree turn on both sides of the cut brought the item back to 10 moving frames.
The third is the exit stealing the poster. Item 01 is a story about opening, but three helper buttons leaving together changed more pixels than three arriving, so an empty corner won the first impression. Pulling the exits apart to 78, 84, and 90 percent and shrinking each one on the way out left an open frame standing. Item 06 failed in the opposite direction: a turning ring is the whole idea, so only 1.83% of the stage ever changes — the smallest figure in the set, and the reason its card and button were resized until the ring could carry a thumbnail.
Narrow screens break things last. Every part occupies a 480×300 slot on the page and drops to 320×200 on a phone, which leaves 174px of usable height once the stage padding is taken out, so at 320px each spacing value goes down and never up. The helper buttons in 01 give up their 168px minimum width for 140px, the chat panel in 08 goes from 148px to 112px, and 09 refuses to store its snap target as a number at all — it recomputes stage width minus button width on every release, because the button is a different size down there. Parts that change width, like the helper buttons in 01, have to be checked for layout shake too — and claiming the space up front is the opposite answer, which is what skeleton loading screens do. All nine sets live in one folder whose archive password is khf267ta, and the React build packed beside the vanilla one behaves the same way on the same numbers.
Accessibility (reduced-motion)
Under prefers-reduced-motion: reduce, every loop halts on a result instead of a blank: the speed dial rests open, the star rests drawn and filled, the theme button rests on the moon, and the draggable one rests grabbed at the left edge. Someone who switched motion off still has to be able to tell which state the button is in.
For icon buttons, the job splits cleanly into a name and a state. The name is aria-label; expanding goes to aria-expanded (01, 07, 08), pressed or not goes to aria-pressed (04, 05), and 08 adds aria-live for a reply that arrives without being asked for. Focus needs a guard as well, which is why 07 and 08 both call focus({ preventScroll: true }) — these parts run inside iframes on the grid page, and an unguarded call yanks the parent article along with it. In 06, the hover rule and the :focus-visible rule are written as one selector list, so nobody arriving by keyboard is given less than someone arriving by mouse.
| Item | By eye | By screen reader | Control |
|---|---|---|---|
| 01 Speed dial | plus turns 45° · three rise | aria-expanded toggled |
click · Escape |
| 02 Toolbar FABs | bar leaves downward | per-button aria-label |
scroll |
| 03 Point ripple | circle from the touched pixel | alert button name | pointer · Enter |
| 04 Star draw | line drawn, then filled | aria-pressed · saved notice |
click |
| 05 Theme button | sun and moon swap | aria-pressed · name rewritten |
click |
| 06 Close ring | dashed ring starts turning | close button name | hover · Tab focus |
| 07 Search FAB | circle widens into a field | aria-expanded · field name |
click · Escape |
| 08 Help FAB | panel lifts into place | aria-live reply notice |
click · Enter |
| 09 Drag FAB | snaps to the nearer edge | position read out | long-press drag · ←→ |
Naming a graphic-only control is spelled out in the MDN aria-label reference, and the registered custom property behind 07 is covered on the MDN @property page. Neighboring navigation parts are collected under the nav category, and the ones with real JavaScript behind them live under the JS category.
FAQ
How much contrast does an icon need?
An icon is a graphic, not text, so the bar is 3:1 against its background rather than the 4.5:1 that body text has to clear. Cream on near-black, as in the alert button in 03, sits far above that line, and so does near-black on a white card in 06. The yellow glyphs in 02 are the ones to watch, which is why they ride a dark bar instead of the page. Naming a control whose whole meaning is one check mark hits the same wall in custom checkbox animations.
Where should a floating action button sit?
The bottom right is the default because that is where a right thumb lands, but it is not a law. Left-handed people, tall phones, and a bottom tab bar all break it in different ways. That is the reason 09 exists at all: rather than choosing the spot for the reader, it lets them hold and move the button and then snaps it to the nearer edge. When moving is not on the table, clear the height of the bottom bar at the very least.
Can these icon button html css patterns work without JavaScript?
The visual half mostly can. Hover, focus, and a checkbox trick cover the ring in 06 and a large part of 04 and 05, and the width flow in 07 is pure CSS once --w is registered. What cannot be faked is reading coordinates for 03, comparing scroll positions for 02, and measuring a hold against 350ms for 09, so those three ship with the handful of lines you see above.








