フローティング ボタン アイコン 9選 — 押す・広がる・動かす
フローティング ボタンとは、画面の隅に浮かんだまま、その画面の代表的な動作をひとつだけ引き受ける丸いボタンのことです。ほとんどは文字を持たず絵ひとつで用件を伝えるため、何をするボタンなのかが支援技術にも届くように、すべてに名前を付けてあります。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 45度回転して動作を取り出すスピードダイヤル
- 02 スクロール方向を読んで隠れるツールバーFAB
- 03 押した場所から広がるアイコンのリップル
- 04 線を描いてから満たすお気に入りの星
- 05 太陽と月が入れ替わるテーマボタン
- 06 リングが回り続ける閉じるボタン
- 07 アイコンから入力欄へ広がる検索FAB
- 08 押すとチャットランチャーが開くヘルプボタン
- 09 長押しでドラッグすると追従するFAB
並べた順は、ボタンが画面の中で背負う仕事の重さの順です。はじめの2つは画面全体の代表動作を握る側で、ひとつ押すと複数の道具に枝分かれし(01)、読んでいる間は自分から席を外します(02)。続く4つは、押されたときの返事の仕方がそれぞれ違います。押された点から波が広がり(03)、星は線を描いてから色を入れ(04)、太陽と月は席を入れ替え(05)、閉じるボタンはリングを回します(06)。最後の3つはボタン自身が形や居場所を変える側で、アイコンが入力欄へ広がり(07)、チャットの板を開いて言葉を積み上げ(08)、長押しで掴めばボタンそのものが引っ越します(09)。フローティング ボタン css だけで足りる部分とスクリプトが要る部分の境目はここで、位置を動かす側はまず値を誰が持つのかを決める必要があります。押したあとに起こることはボタンを押したら変わる9つの状態で、待ち時間そのものを描くことはAIチャットのローディングで別に掘ってあります。
0145度回転して動作を取り出すスピードダイヤル
何が見えるか
作成ボタン1つに書く・写真・録音をすべてまとめるなら、押した後で枝分かれする必要があります。この部品はプラスのアイコンを rotate(45deg) で回して閉じる記号に変え、補助ボタン3つを transition-delay で40ミリ秒ずつずらして浮かせます。
<main class="stage">
<section class="sd is-demo" id="sd" aria-label="作成ツールのスピードダイヤル">
<p class="sd__hint" id="sd-hint"><span class="sd__hint-a">何を作りますか?</span><span class="sd__hint-b">動作を選んでください</span></p>
<ul class="sd__menu" id="sd-menu">
<li class="sd__item">
<button class="sd__sub" type="button" aria-label="書く">
<span class="sd__tag">書く</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="写真を上げる">
<span class="sd__tag">写真を上げる</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="音声を録る">
<span class="sd__tag">音声を録る</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="作成メニューを開く">
<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 ? '作成メニューを閉じる' : '作成メニューを開く');
}
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 · 45度回転して動作を取り出すスピードダイヤル — 計測 累積面積 12.706% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
開閉はクラスだけでなく aria-expanded にも書き込み、Escape でも閉じます。
.sd__sub {
transform: translateY(12px) scale(.6);
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(.2, 1.4, .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; }
02スクロール方向を読んで隠れるツールバーFAB
fab ボタン はスクロールに合わせて画面でどう隠れるか
読んでいる最中に道具が本文を覆うなら、それは道具ではなく邪魔です。この部品は一覧の scrollTop が増えれば下へ読み進めていると見なしてボタンの帯を translateY(150%) で舞台の外へ逃がし、値が減れば連れ戻します。
<main class="stage">
<section class="tb is-demo" id="tb" aria-label="読んでいる間に隠れるツールバーボタン">
<header class="tb__head">
<p class="tb__title">今日の読みもの</p>
<p class="tb__state" id="tb-state">ツール表示中</p>
</header>
<div class="tb__view" id="tb-view" tabindex="0" role="region" aria-label="記事の一覧">
<ul class="tb__list">
<li class="tb__row"><b>01</b> 柔らかな影を載せるカード</li>
<li class="tb__row"><b>02</b> 手を招く入力欄</li>
<li class="tb__row"><b>03</b> 一拍遅れて届くお知らせ</li>
<li class="tb__row"><b>04</b> 畳み開く一覧の呼吸</li>
<li class="tb__row"><b>05</b> 端まで満ちる進捗バー</li>
<li class="tb__row"><b>06</b> 押した場所を覚えるボタン</li>
<li class="tb__row"><b>07</b> スクロールに追う目盛り</li>
<li class="tb__row"><b>08</b> 舞台から消えるチップ</li>
<li class="tb__row"><b>09</b> 指先から咲くリップル</li>
</ul>
</div>
<nav class="tb__bar" id="tb-bar" aria-label="読書ツール">
<button class="tb__fab" type="button" id="tb-top" aria-label="一番上へ">
<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="一覧から探す">
<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="目次を開く">
<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 = '読書中 · ツール格納';
} else if (up && tb.classList.contains('is-hidden')) {
tb.classList.remove('is-hidden');
tbState.textContent = 'ツール表示中';
}
});
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 · スクロール方向を読んで隠れるツールバーFAB — 計測 累積面積 14.57% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
2px のしきい値を置いてあるので、指が少し震えた程度では反応しません。
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');
}
});
03押した場所から広がるアイコンのリップル
どんな動きか
丸ボタンの真ん中からしか波が出ないと、どこを押したのかという手応えが消えます。この部品は pointerdown が拾った座標を円の --x と --y に書き込み、まさにその点から円が scale で膨らんで薄れていきます。
<main class="stage">
<section class="rp is-demo" id="rp" aria-label="押した場所から広がるアイコンボタン">
<button class="rp__btn" type="button" id="rp-btn" aria-label="お知らせを見る">
<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">ボタンのどこでも押してみてください</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 = 'お知らせを確認しました';
});
rpBtn.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
rpHint.textContent = 'お知らせを確認しました';
});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 · 押した場所から広がるアイコンのリップル — 計測 累積面積 5.137% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
膨らませるのは transform なので、レイアウトの計算をやり直しません。
rpBtn.addEventListener('pointerdown', function (e) {
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);
});
04線を描いてから満たすお気に入りの星
何が見えるか
星が一瞬で黄色くなると、お気に入りに入った結果だけが残り、入れる動作そのものは消えます。この部品は輪郭を stroke-dashoffset で100から0まで一周描き、描き終わる460ミリ秒の地点で塗りつぶした星にカットで差し替えます。
<main class="stage">
<section class="st is-demo" id="st" aria-label="お気に入りにしまう商品カード">
<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">春のガーデンワンピース</h2>
<p class="st__meta">淡いミント · フリーサイズ</p>
</div>
<button class="st__btn" type="button" id="st-btn" aria-pressed="false" aria-label="お気に入りに入れる">
<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">お気に入りに入れました</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 ? 'お気に入りから外す' : 'お気に入りに入れる');
stSaved.textContent = stOn ? 'お気に入りに入れました' : 'お気に入りから外しました';
});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 · 線を描いてから満たすお気に入りの星 — 計測 累積面積 15.502% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
押した状態は aria-pressed に残り、カードの下地も同じ瞬間に色づきます。
.st__draw path {
fill: none;
stroke: #17141a;
stroke-width: 10;
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; }
05太陽と月が入れ替わるテーマボタン
画面で起きること
太陽と月を透明度で混ぜると、途中で両方が半分ずつ重なった幽霊のような場面が生まれます。この部品は2つのアイコンを grid-area: 1 / 1 で重ねて置き、transition: opacity 0ms で1コマに切って入れ替え、その後ろのカードと昼夜の文字を steps(1, end) で同じ瞬間に差し替えます。
<main class="stage">
<section class="tm is-demo" id="tm" aria-label="太陽と月が入れ替わるテーマボタン">
<article class="tm__card">
<header class="tm__head">
<span class="tm__avatar" aria-hidden="true"></span>
<span class="tm__who">わたしのノート</span>
</header>
<p class="tm__line tm__line--a">昼は明るい紙で</p>
<p class="tm__line tm__line--b">夜は柔らかいインクで</p>
<p class="tm__line tm__line--c">目の楽なほうへ</p>
</article>
<button class="tm__btn" type="button" id="tm-btn" aria-pressed="false" aria-label="夜のテーマへ変える">
<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">昼</span><span class="tm__t-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 ? '昼のテーマへ変える' : '夜のテーマへ変える');
});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 · 太陽と月が入れ替わるテーマボタン — 計測 累積面積 17.817% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
回転と大きさはイージングに任せてあるため、切るのは見え方だけで、回る動きはなめらかに残ります。
@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); }
}
.tm.is-demo .tm__card {
animation-name: tm-loop-card;
animation-timing-function: steps(1, end);
}
06リングが回り続ける閉じるボタン
どんな動きか
閉じる記号は小さく、押してよいものか迷わせがちです。この部品はその周りに破線のリングを置き、マウスが乗るか :focus-visible で焦点が届いたときだけリングを回します。
<main class="stage">
<section class="cl is-demo" id="cl" aria-label="リングが回る閉じるボタン付きのお知らせカード">
<article class="cl__card">
<p class="cl__msg">初回注文10%クーポンはあと10日残っています</p>
<button class="cl__btn" type="button" id="cl-btn" aria-label="お知らせを閉じる">
<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">お知らせを閉じました <button class="cl__back" type="button" id="cl-back">元に戻す</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 · リングが回り続ける閉じるボタン — 計測 累積面積 1.828% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
キーボードで来た人とマウスで来た人が同じ合図を受け取り、ポインタが外れればリングはすぐに止まり、最初の角度へ戻ります。
.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); }
}
07アイコンから入力欄へ広がる検索FAB
何が見えるか
虫めがねだけだった場所が入力欄になるには、幅そのものが流れる必要があります。この部品は --w を @property で長さ型として登録し、48px から264px まで transition で流します。
<main class="stage">
<section class="se is-demo" id="se" aria-label="アイコンから入力欄に広がる検索FAB">
<p class="se__hint" id="se-hint">検索を開く</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="検索を開く">
<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="何をお探ししましょう?" aria-label="検索語の入力" />
<span class="se__q" id="se-q" aria-hidden="true">春 ワンピース</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 ? '検索を閉じる' : '検索を開く');
seHint.textContent = v ? '検索語を入力してみてください' : '検索を開く';
if (v) {
window.setTimeout(function () { seInput.focus({ preventScroll: true }); }, 320);
} else {
seLog.textContent = seInput.value.trim() ? '検索語を残しました' : '';
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 · アイコンから入力欄へ広がる検索FAB — 計測 累積面積 10.802% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
広がり切ると焦点は preventScroll を添えて入力欄に移り、開いたことは aria-expanded が伝えます。
@property --w {
syntax: "<length>";
inherits: false;
initial-value: 48px;
}
.se__pill {
--w: 48px;
width: var(--w);
transition: --w 320ms cubic-bezier(.2, .8, .2, 1),
border-color 140ms linear;
}
08押すとチャットランチャーが開くヘルプボタン
画面で起きること
問い合わせのボタンが別のページへ放り出すと、見ていた画面を失います。この部品は同じ画面の上で板を translateY で持ち上げて開き、送った言葉は実際に吹き出しとして積み上がります。
<main class="stage">
<section class="ch is-demo" id="ch" aria-label="チャットランチャーを開くヘルプボタン">
<div class="ch__panel" id="ch-panel" role="dialog" aria-label="ヘルプチャット" aria-modal="false">
<header class="ch__head">
<span class="ch__name">何をお手伝いしましょう?</span>
</header>
<div class="ch__log" id="ch-log" aria-live="polite">
<p class="ch__bubble ch__bubble--bot">気になることを書いてください。平日昼は人が答えます。</p>
<p class="ch__bubble ch__loop-me" aria-hidden="true">配送はいつですか?</p>
<p class="ch__bubble ch__loop-dots" aria-hidden="true">...</p>
<p class="ch__bubble ch__loop-reply" aria-hidden="true">明日の朝に先発します。</p>
</div>
<form class="ch__form" id="ch-form">
<input class="ch__input" id="ch-input" type="text" placeholder="メッセージを書いてみてください" aria-label="メッセージ入力" />
<button class="ch__send" type="submit" aria-label="メッセージを送る">
<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="ヘルプチャットを開く">
<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 ? 'ヘルプチャットを閉じる' : 'ヘルプチャットを開く');
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 = '受け付けました。少しお待ちください。';
}, 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 · 押すとチャットランチャーが開くヘルプボタン — 計測 累積面積 27.706% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
板が開く場所はあらかじめ確保してあるので、開いても下のボタンが押し出されません。
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);
});
09長押しでドラッグすると追従するFAB
どんな動きか
浮いているボタンが、よりによって読みたい文字の上に座ってしまうことがあります。この部品は350ミリ秒より長く押して初めて掴んだ状態に移り、そこからはボタン自身が指を追って translate で動きます。
<main class="stage">
<section class="dg is-demo" id="dg" aria-label="長押しでドラッグすると移動するFAB">
<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">右にくっついています</p>
<button class="dg__fab" type="button" id="dg-fab" aria-label="ヘルプボタンの位置を動かす">
<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">350ms より長く押してからドラッグしてください</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' ? '左にくっついています' : '右にくっついています';
}
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 = 'つかみました · 離すとスナップ';
}, 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 — 計測 累積面積 10.568% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
離せば左右の近いほうの端にスナップし、軽いタップは普段どおりのクリックのまま残ります。
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('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)';
});
どこで壊れるか — 落とし穴
一番よく壊れるのは、文字のないボタンに名前を付け忘れる箇所です。9つのうち6つはアイコン1つきりなので、名前を抜くと読み上げには「ボタン」としか届きません。そこで全部に aria-label を置き、状態が変われば名前も一緒に書き換えます。01のFABは閉じているとき「作成メニューを開く」、開いているとき「作成メニューを閉じる」です。07の検索FABも同じ作法で、05のテーマボタンは今押せば何になるのかを名前に書いてあります。
2番目は、動いたコマ数と変わった面積がまったく一致しないことです。24コマのプレビューで測ると、板ごと開く08は変化の合計面積が27.706%で9つの中で最も大きく、リングだけが回る06は1.828%で最も小さく、その差は15倍ありました。06は24コマ中23コマで何かが動いているのに、動く範囲が細い破線の輪だけなので合計が伸びません。そこでカードの幅を min(252px, 92vw) に絞り、ボタンを56pxまで大きくして、リングが画面で占める取り分を増やしてあります。コマ数だけを見て「よく動いている」と判断すると、この種の部品は代表画像で必ず負けます。
3番目は、消える瞬間のほうが代表画像に選ばれることです。24コマから一番大きく変わったコマを拾う仕組みなので、01のように補助ボタン3つが一斉に引っ込む部品では、開いたコマではなく空の画面が代表になりかねません。今は退場を1コマずつずらし、消えるときに縮小も重ねて、動いたコマが24コマ中9コマに落ち着いています。05はその逆で、steps(1, end) のカットは1コマで終わるため、切り替えだけでは変わるコマがほとんど残りません。カットの前後に円盤が回る区間を広く取り、動いたコマを10コマまで戻しました。
最後は狭い画面です。部品は記事の中では480×300の枠に入り、スマホでは320×200まで縮みます。舞台の余白を引くと縦に使えるのは174pxしかないため、340px 以下では間隔を増やさず減らす方針にしました。01の補助ボタンは最小幅を168pxから140pxに、文字を13pxから11pxに落とし、08のチャット板は高さを148pxから112pxに縮めます。09は画面が狭くなるとボタンの大きさも変わるので、スナップの着地点を固定値で書かず、画面の幅からボタンの幅を引いてそのつど計算します。縮めたあとに9つとも測り直し、320×200の枠から中身がはみ出す部品が1つも残っていないことを確かめました。狭い画面で崩れる部品は、広い画面でどれだけ気持ちよく動いても使えません。01の補助ボタンのように幅が変わる部品は、枠が揺れないかまで一緒に検査します。先に場所を確保しておくという逆の答えがスケルトン ローディングです。9つ分を1つのフォルダーにまとめてあり、その圧縮ファイルのパスワードは khf267ta です。中には今見ている素の版と、同じ動きをするReact版が並んで入っています。
アクセシビリティ
動きを減らす設定(prefers-reduced-motion: reduce)が入った画面では、9つとも繰り返しのループが止まり、結果の状態だけが残ります。スピードダイヤルは開いたまま、星は描き終わって塗られたまま、テーマボタンは月が出たまま、チャットは返事まで届いた画面のまま立ち止まります。09は掴んだ瞬間の大きさで左端に、02の道具は隠れずに見えている状態で止まります。動きが消えても「今はどの状態なのか」だけは消してはならないからです。
アイコンボタンのアクセシビリティは、名前と状態の2つに整理できます。名前は aria-label で与え、開け閉めは aria-expanded(01・07・08)、オンとオフは aria-pressed(04・05)で伝えます。焦点の扱いも省いていません。07と08が入力欄に焦点を移すときに preventScroll を併用するのは、これらの部品が一覧画面の iframe の中に置かれていて、そのまま呼ぶと親のページごと引きずられるからです。06はマウスが乗ったときとキーボードの焦点が届いたときに同じ合図を出すよう、:focus-visible をホバーと並べて束ねてあります。
| 項目 | 見た目 | スクリーンリーダー | 操作 |
|---|---|---|---|
| 01 スピードダイヤル | 45度の回転と3方向の展開 | aria-expanded の切替 |
クリック · Escape |
| 02 ツールバーFAB | 下へ退く帯 | ボタンごとの aria-label |
スクロール |
| 03 アイコンのリップル | 押した点から広がる波 | お知らせボタンの名前 | ポインター · Enter |
| 04 お気に入りの星 | 線を描いてから塗り | aria-pressed · 保存の案内 |
クリック |
| 05 テーマボタン | 太陽と月の入れ替え | aria-pressed · 名前の書き換え |
クリック |
| 06 閉じるリング | 回り始めるリング | 閉じるボタンの名前 | ホバー · Tab の焦点 |
| 07 検索FAB | アイコンから欄へ | aria-expanded · 欄の名前 |
クリック · Escape |
| 08 ヘルプFAB | 持ち上がる板 | aria-live による返事の案内 |
クリック · Enter |
| 09 ドラッグFAB | 掴んだ先へのスナップ | 位置の案内文 | 長押しドラッグ · ←→ |
アイコンだけのボタンに名前を与える方法は、MDN の aria-label の解説にまとまっています。同じ系統のナビゲーション部品はナビのカテゴリに、JavaScript が実際に走る部品はJS のカテゴリに集めてあります。
FAQ
アイコンだけのボタンに文字は要りますか?
画面に見える文字はなくてかまいませんが、名前は必ず要ります。aria-label で与えれば目には映らず、読み上げにだけ届きます。02の3つのボタンのように横並びのアイコンが続く場面は特にそうで、見る人は矢印・虫めがね・三本線を見分けられても、聞く人には「ボタン ボタン ボタン」としか届きません。名前は「一番上へ」のように、押すと何が起きるかの言葉で書きます。チェックの入り切りだけで意味が決まる部品の名前付けも、チェックボックスのカスタムアニメーションで同じ壁に当たりました。
アイコンの色はどれくらい濃ければよいですか?
アイコンは文字ではなく絵なので、判断の基準が違います。意味を運ぶ絵は背景との差が3対1あれば足り、本文の文字に求められる4.5対1はここには当てはまりません。この記事では、濃い墨色の面にクリーム色のアイコンを置くか、白いカードの上に墨色のアイコンを置くかの2通りに寄せてあります。逆に02の黄色いアイコンのように明るい色を使う場合は、墨色の帯に乗せて初めて絵として読めるようになります。
フローティング ボタン デザイン では画面のどこに置くのがよいですか?
右手の親指が届く右下が基本ですが、決まりではありません。左利きの人や画面の大きい端末ではその場所がかえって遠く、下部にタブバーがあれば重なります。09を作った理由がそこにあります。置き場所をこちらで決める代わりに長押しで動かせるようにし、指を離せば近いほうの端に吸い付きます。動かせない作りにするなら、せめてタブバーの高さぶんは浮かせておきます。








