플로팅 버튼 아이콘 버튼 패턴 9종 — 누르고 끌고 펼쳐지는
플로팅 버튼은 화면 한 귀퉁이에 떠 있으면서 그 화면의 대표 동작 하나를 맡는 둥근 버튼입니다. 아홉 개 대부분 글자 없이 그림 하나로 말을 걸어야 하므로, 무엇을 하는 버튼인지 보조기기에도 들리게 이름을 달았습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 45도로 돌아 동작을 꺼내는 스피드 다이얼
- 02 스크롤 방향을 읽고 숨는 툴바 FAB
- 03 눌린 자리에서 번지는 아이콘 리플
- 04 선을 그려 채우는 즐겨찾기 별
- 05 해와 달이 맞바뀌는 테마 버튼
- 06 링이 빙글 도는 닫기 버튼
- 07 아이콘이 입력칸으로 펼쳐지는 검색 FAB
- 08 누르면 채팅 창이 열리는 도움말 버튼
- 09 길게 눌러 끌면 따라오는 FAB
순서는 버튼이 화면에서 맡는 일의 크기 순입니다. 먼저 화면 전체의 대표 동작을 쥔 둘이 옵니다. 하나를 눌러 여러 갈래를 꺼내고(01), 읽는 동안에는 스스로 비켜섭니다(02). 다음 넷은 누름에 답하는 방식이 서로 다릅니다. 눌린 지점에서 물결이 번지고(03), 별은 선을 그린 뒤 색을 채우며(04), 해와 달은 자리를 맞바꾸고(05), 닫기는 링을 돌립니다(06). 마지막 셋은 버튼이 자기 모양이나 자리를 바꾸는 쪽입니다. 아이콘이 입력칸으로 펼쳐지고(07), 채팅 판을 열어 붙들고(08), 길게 눌러 끌면 버튼 자체가 옮겨 갑니다(09). 플로팅 버튼 css 만으로 되는 것과 스크립트가 있어야 하는 것이 여기서 갈리는데, 자리를 옮기거나 스크롤을 읽는 쪽은 값을 누가 들고 있느냐를 먼저 정해야 합니다. 누른 다음에 벌어지는 일은 버튼 클릭 효과와 상태에서, 기다리는 시간 자체를 그리는 일은 AI 채팅 로딩 애니메이션에서 따로 팠습니다.
0145도로 돌아 동작을 꺼내는 스피드 다이얼
무엇이 보이나
만들기 버튼 하나에 글쓰기·사진·녹음을 다 담으려면 누른 다음에 갈라져야 합니다. 이 부품은 더하기 아이콘을 rotate(45deg) 로 돌려 닫기 표시로 바꾸고, 보조 버튼 셋을 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
열림과 닫힘은 클래스와 함께 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
2픽셀 문턱을 두어 손가락이 살짝 떨릴 때는 반응하지 않습니다.
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
커지는 것은 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
누름은 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해와 달이 맞바뀌는 테마 버튼
화면에서 일어나는 일
해와 달을 투명도로 섞으면 중간에 둘이 반쯤 겹친 유령 장면이 생깁니다. 이 부품은 두 아이콘을 grid-area: 1 / 1 로 겹쳐 두고 transition: opacity 0ms 로 한 프레임에 잘라 맞바꾸며, 안쪽 미리보기 판은 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
회전과 크기는 이징을 그대로 두어, 자르는 것은 보임뿐이고 도는 것은 부드럽습니다.
@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% 쿠폰이 열흘 뒤까지 남았습니다</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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
키보드로 온 사람과 마우스로 온 사람이 같은 신호를 받고, 손을 떼면 링은 곧바로 멈춥니다.
.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 로 길이형으로 등록해 두고 48픽셀에서 264픽셀까지 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
다 펼쳐진 뒤 초점은 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
판이 열릴 자리는 미리 확보해 두어 열려도 아래 칸이 밀리지 않습니다.
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
놓으면 좌우 중 가까운 가장자리로 스냅되고, 짧게 누르는 탭은 평소의 클릭 그대로 남습니다.
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)';
});
어디서 깨지는가 — 함정
가장 자주 깨지는 곳은 글자 없는 버튼에 이름을 안 다는 것입니다. 아홉 개 중 여섯 개는 아이콘 하나뿐이라, 이름을 빼면 낭독기에서는 그냥 「버튼」으로 들립니다. 그래서 전부 aria-label 을 달았고, 상태가 바뀌면 이름도 같이 바꿉니다. 01 의 FAB 은 닫혀 있을 때 「만들기 메뉴 열기」, 열려 있을 때 「만들기 메뉴 닫기」입니다. 07 의 검색 FAB 도 같은 방식이고, 05 의 테마 버튼은 지금 무엇을 누르면 무엇이 되는지를 이름에 적었습니다.
두 번째는 한 번에 꺼지고 켜지는 전환입니다. 05 를 처음 만들 때는 해와 달을 투명도로 교차시켰는데, 24장을 뽑는 미리보기에서 두 아이콘이 반쯤 겹친 장면이 그대로 대표 그림이 되었습니다. 그래서 낮과 밤 판의 보임은 steps(1, end) 로 한 프레임에 자르고, 해와 달은 회전과 크기를 이징에 맡겨 겹치는 순간에도 각도가 갈리게 했습니다. 그렇게 하면 컷만으로는 변한 장이 두세 장밖에 안 되는 문제가 따라옵니다. 05 는 컷 앞뒤로 원반이 180도 도는 구간을 넓게 잡아 24장 중 10장이 움직이게 맞췄습니다.
세 번째는 사라지는 장이 대표 그림이 되는 것입니다. 01 은 열림이 주제인데, 보조 버튼 셋이 한꺼번에 사라지는 장의 변화가 더 커서 처음에는 빈 화면이 대표로 뽑혔습니다. 퇴장을 78·84·90퍼센트로 한 장씩 떼어 놓고 사라질 때 크기까지 줄이자 열린 장이 남았습니다. 06 은 반대 문제였습니다. 링만 도는 부품이라 변한 넓이가 아홉 개 중 가장 작아, 카드를 288픽셀에서 252픽셀로 줄이고 버튼을 44픽셀에서 56픽셀로 키워 링이 화면에서 차지하는 몫을 늘렸습니다. 그러고도 넓이는 1.83퍼센트로 아홉 개 중 가장 작지만, 움직인 장이 24장 중 23장으로 아홉 개 중 가장 많아 도는 것 자체는 끊기지 않고 읽힙니다. 07 도 같은 이유로 무대를 흰색에서 주황으로 옮겼습니다. 흰 무대에 흰 알약이 놓이니 펼쳐지는 폭이 화면에서 읽히지 않았고, 무대를 바꾸자 변한 넓이가 8.00퍼센트에서 10.81퍼센트로, 변한 픽셀의 평균 차이가 90.8에서 125.3으로 올라갔습니다.
마지막은 좁은 화면입니다. 부품은 넓은 화면에서 480×300 칸에 들어가고 폰에서는 320×200으로 줄어듭니다. 무대 여백을 빼면 세로로 쓸 수 있는 것은 174픽셀뿐이라, 320픽셀에서는 간격을 키우지 않고 줄였습니다. 01 의 보조 버튼은 최소 폭을 168픽셀에서 140픽셀로, 08 의 채팅 판은 높이를 148픽셀에서 112픽셀로 줄입니다. 09 는 좁은 화면에서 버튼이 작아지는 만큼 스냅이 닿는 자리도 달라지므로, 끝 위치를 고정값으로 적어 두지 않고 화면 폭에서 버튼 폭을 뺀 값으로 그때그때 계산합니다. 01 의 보조 버튼처럼 폭이 달라지는 부품은 칸이 흔들리는지까지 함께 검사해야 하는데, 아예 자리를 먼저 잡아 두는 반대쪽 답이 스켈레톤 로딩 화면입니다. 아홉 벌을 한 폴더에 담아 두었고 그 압축 파일의 비밀번호는 khf267ta 이며, 안에는 지금 보는 바닐라 판과 같은 동작의 React 판이 함께 들어 있습니다.
접근성
움직임을 줄여 달라는 설정(prefers-reduced-motion: reduce)이 켜진 화면에서는 아홉 벌 전부 반복 루프가 멈추고 결과 상태만 남습니다. 스피드 다이얼은 펼쳐진 채로, 별은 다 그려져 채워진 채로, 테마 버튼은 달이 뜬 채로 섭니다. 움직임이 사라져도 「지금 어떤 상태인가」는 남아야 하기 때문입니다.
아이콘 버튼의 접근성은 이름과 상태 두 가지로 정리됩니다. 이름은 aria-label 로 주고, 여닫이는 aria-expanded(01·07·08), 켜짐과 꺼짐은 aria-pressed(04·05)로 알립니다. 초점 처리도 빼놓지 않았습니다. 07 과 08 이 입력칸에 초점을 줄 때 preventScroll 을 함께 쓰는 이유는 이 부품들이 목록 화면의 iframe 안에 들어 있어, 가드 없이 부르면 부모 페이지가 통째로 끌려가기 때문입니다. 06 은 마우스를 올렸을 때와 키보드 초점이 닿았을 때 같은 신호를 주도록 :focus-visible 을 호버와 나란히 묶었습니다.
| 항목 | 눈으로 | 낭독기로 | 조작 |
|---|---|---|---|
| 01 스피드 다이얼 | 45도 회전 + 세 갈래 | 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 문서에 정리되어 있습니다. 같은 계열의 버튼 부품은 CTA·버튼 카테고리에, 자바스크립트가 실제로 도는 부품은 JS 카테고리에 모여 있습니다.
FAQ
아이콘만 있는 버튼에 글자를 꼭 넣어야 하나요?
화면에 보이는 글자는 없어도 되지만, 이름은 반드시 있어야 합니다. aria-label 로 주면 눈에는 안 보이고 낭독기에만 들립니다. 이 아홉 개 중 02 의 세 버튼처럼 나란히 붙은 아이콘들은 특히 그렇습니다. 화살표·돋보기·줄 세 개가 나란히 있으면 보는 사람은 구분하지만 듣는 사람에게는 「버튼 버튼 버튼」입니다. 이름은 「맨 위로」처럼 그 버튼을 누르면 무슨 일이 생기는지로 적습니다. 체크 표시 하나로 뜻이 정해지는 부품의 이름 짓기도 체크박스 커스텀 애니메이션에서 같은 벽에 부딪혔습니다.
아이콘 색은 얼마나 진해야 하나요?
아이콘은 글자가 아니라 그림이라 기준이 다릅니다. 버튼의 뜻을 전하는 그림은 배경과 3 대 1 이상이면 되고, 본문 글자에 요구되는 4.5 대 1은 여기에 해당하지 않습니다. 이 편에서 실제로 잰 값으로 보면 06 의 흰 카드 위 먹색 링이 18.24 대 1로 가장 높고, 03 의 먹색 버튼 위 크림색 종 아이콘이 17.11 대 1로 그다음이며, 01 과 07 의 파란 FAB 위 크림색 아이콘은 4.25 대 1로 가장 낮지만 여전히 기준 위입니다. 예외는 04 의 채워진 별입니다. 노랑과 흰 카드는 1.44 대 1뿐이라 색만으로는 형태가 안 잡히는데, 그 별에 먹색 테두리를 10 두께로 남겨 두어 윤곽이 대비를 책임집니다.
플로팅 버튼 디자인에서 버튼은 화면 어디에 두는 게 좋나요?
오른손 엄지가 닿는 오른쪽 아래가 기본이지만 고정된 규칙은 아닙니다. 왼손잡이거나 화면이 큰 폰에서는 그 자리가 오히려 멀고, 하단 탭 막대가 있으면 겹칩니다. 09 를 만든 이유가 그것입니다. 자리를 우리가 정해 주는 대신 길게 눌러 옮기게 하고, 놓으면 가까운 가장자리에 붙게 했습니다. 옮길 수 없다면 최소한 하단 막대 높이만큼은 띄워 둡니다.








