04Favorite star that draws its line, then fills
Pressing draws the star outline around once with stroke-dashoffset, and once the line closes the fill swaps in a steps(1, end) cut, the pressed state kept in aria-pressed.
Best forFavorites that collect products and posts, read-later lists
stroke-dashoffsetsteps(1, end)aria-pressed
<main class="stage">
<section class="st is-demo" id="st" aria-label="A product card kept in favorites">
<article class="st__card">
<span class="st__thumb" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M224,56V178.06l-39.72-39.72a8,8,0,0,0-11.31,0L147.31,164,97.66,114.34a8,8,0,0,0-11.32,0L32,168.69V56a8,8,0,0,1,8-8H216A8,8,0,0,1,224,56Z" opacity=".2"/><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,16V158.75l-26.07-26.06a16,16,0,0,0-22.63,0l-20,20-44-44a16,16,0,0,0-22.62,0L40,149.37V56ZM40,172l52-52,80,80H40Zm176,28H194.63l-36-36,20-20L216,181.38V200ZM144,100a12,12,0,1,1,12,12A12,12,0,0,1,144,100Z"/></svg></span>
<div class="st__body">
<h2 class="st__title">Spring garden dress</h2>
<p class="st__meta">Pale mint · one size</p>
</div>
<button class="st__btn" type="button" id="st-btn" aria-pressed="false" aria-label="Add to favorites">
<span class="st__star" aria-hidden="true">
<svg class="st__base" viewBox="0 0 256 256" width="1em" height="1em"><path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Z"/></svg>
<svg class="st__draw" viewBox="0 0 256 256" width="1em" height="1em"><path pathLength="100" d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Z"/></svg>
<svg class="st__fill" viewBox="0 0 256 256" width="1em" height="1em" aria-hidden="true"><path pathLength="100" d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Z"/></svg>
</span>
</button>
<p class="st__saved" id="st-saved" aria-live="polite">Added to favorites</p>
</article>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 선을 그려 채우는 즐겨찾기 별 — 그리기는 stroke-dashoffset, 채움 전환은 컷
$duration: 460ms;
$easing: $ease-out;
$color: $stage-yellow;
@include stage($stage-paper);
.st { display: grid; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; }
.st__card {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: $sp-3;
box-sizing: border-box;
width: min(292px, 92vw);
padding: $sp-4;
border: 1px solid rgba(23, 20, 26, .16);
border-radius: $r-card;
background: $stage-paper;
transition: background 0ms 460ms;
}
.st__thumb {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: $r-control;
background: rgba(47, 109, 246, .14);
color: $subject-blue;
font-size: 26px;
}
.st__body { min-width: 0; }
.st__title {
margin: 0;
color: $subject-ink;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.st__meta {
margin: 0;
color: rgba(23, 20, 26, .72);
font-size: 11px;
line-height: 1.35;
}
// ── 별 버튼: 두 별을 겹쳐 두고 그리기 → 채움 컷 순서로 바뀐다 ──
.st__btn {
position: relative;
display: inline-grid;
place-items: center;
width: 48px;
height: 48px;
margin: 0;
padding: 0;
border: 1px solid rgba(23, 20, 26, .18);
border-radius: 50%;
background: $stage-paper;
cursor: pointer;
}
.st__btn:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.st__star {
position: relative;
display: inline-block;
width: 30px;
height: 30px;
font-size: 30px;
}
.st__base,
.st__draw,
.st__fill {
position: absolute;
inset: 0;
display: block;
}
// 담기 전에도 별자리가 보여야 한다 — 옅은 윤곽을 늘 깔아 둔다
.st__base path {
fill: none;
stroke: rgba(23, 20, 26, .28);
stroke-width: 10;
stroke-linejoin: round;
}
// 그려지는 테두리 — pathLength="100" 덕에 100 에서 0 으로 그린다
.st__draw path {
fill: none;
stroke: $subject-ink;
stroke-width: 10;
stroke-linecap: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
transition: stroke-dashoffset $duration $easing;
}
// 채워진 별 — 반투명 섞임 없이 컷으로 바뀐다
.st__fill path {
fill: $color;
stroke: $subject-ink;
stroke-width: 10;
}
.st__fill { opacity: 0; transition: opacity 0ms $duration; }
.st__saved {
position: absolute;
right: $sp-2;
bottom: $sp-1;
margin: 0;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-size: 10px;
font-weight: 700;
line-height: 1.6;
opacity: 0;
}
// 정적 경로 — .is-on 에서 그리기가 끝나고 채움·팝·카드 물들임이 남는다
.st.is-on .st__draw path { stroke-dashoffset: 0; }
.st.is-on .st__fill { opacity: 1; }
.st.is-on .st__star { animation-name: st-pop; animation-duration: $dur-quick; animation-timing-function: $ease-pop; animation-iteration-count: 1; }
.st.is-on .st__card { background: rgba(255, 210, 63, .22); }
.st.is-on .st__saved { opacity: 1; transition: opacity 0ms $duration; }
@keyframes st-pop {
0% { transform: scale(1); }
55% { transform: scale(1.22); }
100% { transform: scale(1); }
}
// ── 격자 미리보기 전용 2초 루프: 그리기 → 채움 컷 → 팝 → 되돌림 ──
@keyframes st-loop-draw {
0%, 10% { stroke-dashoffset: 100; }
42%, 82% { stroke-dashoffset: 0; }
94%, 100% { stroke-dashoffset: 100; }
}
@keyframes st-loop-fill {
0%, 41% { opacity: 0; }
42%, 81% { opacity: 1; }
82%, 100% { opacity: 0; }
}
@keyframes st-loop-card {
0%, 41% { background: $stage-paper; }
42%, 81% { background: rgba(255, 210, 63, .22); }
82%, 100% { background: $stage-paper; }
}
@keyframes st-loop-saved {
0%, 44% { opacity: 0; }
46%, 80% { opacity: 1; }
82%, 100% { opacity: 0; }
}
@keyframes st-loop-pop {
0%, 42% { transform: scale(1); }
50% { transform: scale(1.22); }
58%, 100% { transform: scale(1); }
}
.st.is-demo {
.st__draw path {
animation-name: st-loop-draw;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.st__fill {
animation-name: st-loop-fill;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st__card {
animation-name: st-loop-card;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st__saved {
animation-name: st-loop-saved;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st__star {
animation-name: st-loop-pop;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.st__card { padding: $sp-3; gap: $sp-2; }
.st__thumb { width: 36px; height: 36px; font-size: 22px; }
.st__title { font-size: 12px; }
.st__btn { width: 40px; height: 40px; }
.st__star { width: 24px; height: 24px; font-size: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.st.is-demo .st__draw path,
.st.is-demo .st__fill,
.st.is-demo .st__card,
.st.is-demo .st__saved,
.st.is-demo .st__star { animation: none !important; transform: none !important; }
// 그린 결과만 남긴다 — 테두리는 완성, 채움은 켜짐
.st.is-demo .st__draw path { stroke-dashoffset: 0 !important; }
.st.is-demo .st__fill { opacity: 1 !important; }
.st.is-demo .st__card { background: rgba(255, 210, 63, .22) !important; }
.st.is-demo .st__saved { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.st {
display: grid;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.st__card {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
box-sizing: border-box;
width: min(292px, 92vw);
padding: 16px;
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 16px;
background: #ffffff;
transition: background 0ms 460ms;
}
.st__thumb {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
font-size: 26px;
}
.st__body {
min-width: 0;
}
.st__title {
margin: 0;
color: #17141a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.st__meta {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
line-height: 1.35;
}
.st__btn {
position: relative;
display: inline-grid;
place-items: center;
width: 48px;
height: 48px;
margin: 0;
padding: 0;
border: 1px solid rgba(23, 20, 26, 0.18);
border-radius: 50%;
background: #ffffff;
cursor: pointer;
}
.st__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.st__star {
position: relative;
display: inline-block;
width: 30px;
height: 30px;
font-size: 30px;
}
.st__base,
.st__draw,
.st__fill {
position: absolute;
inset: 0;
display: block;
}
.st__base path {
fill: none;
stroke: rgba(23, 20, 26, 0.28);
stroke-width: 10;
stroke-linejoin: round;
}
.st__draw path {
fill: none;
stroke: #17141a;
stroke-width: 10;
stroke-linecap: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
transition: stroke-dashoffset 460ms ease-out;
}
.st__fill path {
fill: #ffd23f;
stroke: #17141a;
stroke-width: 10;
}
.st__fill {
opacity: 0;
transition: opacity 0ms 460ms;
}
.st__saved {
position: absolute;
right: 8px;
bottom: 4px;
margin: 0;
padding: 0 8px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-size: 10px;
font-weight: 700;
line-height: 1.6;
opacity: 0;
}
.st.is-on .st__draw path {
stroke-dashoffset: 0;
}
.st.is-on .st__fill {
opacity: 1;
}
.st.is-on .st__star {
animation-name: st-pop;
animation-duration: 220ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
.st.is-on .st__card {
background: rgba(255, 210, 63, 0.22);
}
.st.is-on .st__saved {
opacity: 1;
transition: opacity 0ms 460ms;
}
@keyframes st-pop {
0% {
transform: scale(1);
}
55% {
transform: scale(1.22);
}
100% {
transform: scale(1);
}
}
@keyframes st-loop-draw {
0%, 10% {
stroke-dashoffset: 100;
}
42%, 82% {
stroke-dashoffset: 0;
}
94%, 100% {
stroke-dashoffset: 100;
}
}
@keyframes st-loop-fill {
0%, 41% {
opacity: 0;
}
42%, 81% {
opacity: 1;
}
82%, 100% {
opacity: 0;
}
}
@keyframes st-loop-card {
0%, 41% {
background: #ffffff;
}
42%, 81% {
background: rgba(255, 210, 63, 0.22);
}
82%, 100% {
background: #ffffff;
}
}
@keyframes st-loop-saved {
0%, 44% {
opacity: 0;
}
46%, 80% {
opacity: 1;
}
82%, 100% {
opacity: 0;
}
}
@keyframes st-loop-pop {
0%, 42% {
transform: scale(1);
}
50% {
transform: scale(1.22);
}
58%, 100% {
transform: scale(1);
}
}
.st.is-demo .st__draw path {
animation-name: st-loop-draw;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.st.is-demo .st__fill {
animation-name: st-loop-fill;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st.is-demo .st__card {
animation-name: st-loop-card;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st.is-demo .st__saved {
animation-name: st-loop-saved;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st.is-demo .st__star {
animation-name: st-loop-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.st__card {
padding: 12px;
gap: 8px;
}
.st__thumb {
width: 36px;
height: 36px;
font-size: 22px;
}
.st__title {
font-size: 12px;
}
.st__btn {
width: 40px;
height: 40px;
}
.st__star {
width: 24px;
height: 24px;
font-size: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.st.is-demo .st__draw path,
.st.is-demo .st__fill,
.st.is-demo .st__card,
.st.is-demo .st__saved,
.st.is-demo .st__star {
animation: none !important;
transform: none !important;
}
.st.is-demo .st__draw path {
stroke-dashoffset: 0 !important;
}
.st.is-demo .st__fill {
opacity: 1 !important;
}
.st.is-demo .st__card {
background: rgba(255, 210, 63, 0.22) !important;
}
.st.is-demo .st__saved {
opacity: 1 !important;
}
}// 즐겨찾기 별: 누르면 테두리가 그려지고(step 1) 다 그린 뒤 채움으로 바뀐다(step 2)
var st = document.getElementById('st');
var stBtn = document.getElementById('st-btn');
var stSaved = document.getElementById('st-saved');
var stOn = false;
stBtn.addEventListener('click', function () {
st.classList.remove('is-demo');
stOn = !stOn;
st.classList.toggle('is-on', stOn);
stBtn.setAttribute('aria-pressed', stOn ? 'true' : 'false');
stBtn.setAttribute('aria-label', stOn ? 'Remove from favorites' : 'Add to favorites');
stSaved.textContent = stOn ? 'Added to favorites' : 'Removed from favorites';
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StarDrawButton.module.scss";
export type StarDrawButtonProps = {
title?: string;
meta?: string;
/** 꺼진 상태의 버튼 이름 (누르면 담긴다) */
addLabel?: string;
/** 켜진 상태의 버튼 이름 (누르면 빠진다) */
removeLabel?: string;
savedText?: string;
removedText?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
/** 테두리를 그리는 시간(ms). 채움 컷은 이 시간이 끝난 뒤에 온다 */
duration?: number;
easing?: string;
/** 다 그린 별의 채움색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// vanilla 판 index.html 에 인라인된 Phosphor Duotone 별 — 테두리용·채움용 두 장이 같은 경로를 쓴다
const STAR =
"M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Z";
function Thumb() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path
d="M224,56V178.06l-39.72-39.72a8,8,0,0,0-11.31,0L147.31,164,97.66,114.34a8,8,0,0,0-11.32,0L32,168.69V56a8,8,0,0,1,8-8H216A8,8,0,0,1,224,56Z"
opacity=".2"
/>
<path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,16V158.75l-26.07-26.06a16,16,0,0,0-22.63,0l-20,20-44-44a16,16,0,0,0-22.62,0L40,149.37V56ZM40,172l52-52,80,80H40Zm176,28H194.63l-36-36,20-20L216,181.38V200ZM144,100a12,12,0,1,1,12,12A12,12,0,0,1,144,100Z" />
</svg>
);
}
export default function StarDrawButton({
title = "Spring garden dress",
meta = "Soft mint · one size",
addLabel = "Add to favorites",
removeLabel = "Remove from favorites",
savedText = "Saved to favorites",
removedText = "Removed from favorites",
groupLabel = "Product card you can save to favorites",
demo = true,
duration = 460,
easing = "ease-out",
color = "#ffd23f",
}: StarDrawButtonProps) {
const [on, setOn] = useState(false);
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 누르면 미리보기 루프를 끄고 사람이 만든 상태만 남긴다
function toggle() {
setLive(false);
setOn((v) => !v);
}
const cls = [styles.st, live ? styles.isDemo : "", on ? styles.isOn : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<article className={styles.card}>
<span className={styles.thumb} aria-hidden="true">
<Thumb />
</span>
<div className={styles.body}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.meta}>{meta}</p>
</div>
<button
className={styles.btn}
type="button"
aria-pressed={on}
aria-label={on ? removeLabel : addLabel}
onClick={toggle}
>
<span className={styles.star} aria-hidden="true">
{/* 바탕 — 담기 전에도 별자리가 보이게 옅은 윤곽을 늘 깔아 둔다 */}
<svg className={styles.base} viewBox="0 0 256 256" width="1em" height="1em">
<path d={STAR} />
</svg>
{/* 테두리 — pathLength 100 이라 100 → 0 으로 한 바퀴 그려진다 */}
<svg className={styles.draw} viewBox="0 0 256 256" width="1em" height="1em">
<path pathLength={100} d={STAR} />
</svg>
{/* 채움 — 반투명으로 섞지 않고 그리기가 끝난 뒤 컷으로 나타난다 */}
<svg className={styles.fill} viewBox="0 0 256 256" width="1em" height="1em" aria-hidden="true">
<path pathLength={100} d={STAR} />
</svg>
</span>
</button>
<p className={styles.saved} aria-live="polite">
{on ? savedText : removedText}
</p>
</article>
</section>
);
}04 Favorite star that draws its line, then fills — Measured area 15.502% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · 9 Icon Button CSS Patterns — Press, Drag, Expand


