블로그 위젯 디자인 9종 — 공유·후기·지도 임베드 카드
블로그 위젯 디자인은 본문 옆이나 아래에 따로 붙어 공유·후기·문의 같은 한 가지 일을 대신하는 작은 카드다. 실제 유튜브·X·구글맵 API 를 불러오지 않고도 그 모양만 CSS 로 흉내 내면, 코드를 몰라도 복붙 몇 줄로 진짜처럼 보이는 카드를 붙일 수 있다.
01 embed-social-share-bar
02 embed-video-play-overlay
03 embed-x-post-mock
04 embed-testimonial-card
05 embed-map-location
06 embed-podcast-player
07 embed-github-repo-card
08 embed-newsletter-signup
09 embed-live-chat-bubble
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 소셜 공유 버튼 로우
- 02 영상 임베드 재생 버튼 오버레이
- 03 X 포스트 임베드 카드 목업
- 04 후기 임베드 위젯
- 05 지도·위치 임베드 카드
- 06 팟캐스트 플레이어 임베드 위젯
- 07 깃허브 저장소 통계 카드
- 08 뉴스레터 구독 임베드 위젯
- 09 라이브 채팅 버블 런처
아홉 개는 인기순이 아니라 방문자가 글을 읽는 동안 실제로 맞닥뜨리는 순서로 놓았다. 글 아래 공유 줄(01)을 지나 눈을 붙잡는 영상 카드(02)를 보고, 신뢰를 굳히는 X 포스트(03)와 후기(04)를 거쳐 찾아오게 만드는 위치 카드(05)에 닿는다. 그다음 팟캐스트(06)와 깃허브(07)로 더 볼거리를 주고, 떠나기 전 뉴스레터(08)로 붙잡고, 마지막까지 화면에 남아 있는 플로팅 버튼 디자인, 즉 채팅 버블(09)로 끝난다. 남의 페이지에 얹히는 조각이라는 점은 같지만 재생 컨트롤까지 직접 만드는 쪽은 동영상 플레이어 만들기에, 위젯 안 글자가 언어마다 비지 않는지 확인하는 절차는 다국어 처리 검수에 있습니다.
01소셜 공유 버튼 로우
위젯 디자인 가이드로 볼 때 무엇이 보이나
핑크·민트·라일락·하늘색 원형 버튼 네 개가 scale 로 한 번씩 튀어 오르며 순서대로 나타난다.

<main class="stage is-demo">
<div class="sbar" role="group" aria-label="공유">
<button class="sbar__btn sbar__btn--pink" type="button" data-action="copy" aria-label="링크 복사">
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true"><g fill="currentColor"><path d="m209.94 113.94l-96 96a48 48 0 0 1-67.88-67.88l96-96a48 48 0 0 1 67.88 67.88" opacity=".2"/><path d="M165.66 90.34a8 8 0 0 1 0 11.32l-64 64a8 8 0 0 1-11.32-11.32l64-64a8 8 0 0 1 11.32 0M215.6 40.4a56 56 0 0 0-79.2 0l-30.06 30.05a8 8 0 0 0 11.32 11.32l30.06-30a40 40 0 0 1 56.57 56.56l-30.07 30.06a8 8 0 0 0 11.31 11.32l30.07-30.11a56 56 0 0 0 0-79.2m-77.26 133.82l-30.06 30.06a40 40 0 1 1-56.56-56.57l30.05-30.05a8 8 0 0 0-11.32-11.32L40.4 136.4a56 56 0 0 0 79.2 79.2l30.06-30.07a8 8 0 0 0-11.32-11.31"/></g></svg>
</button>
<button class="sbar__btn sbar__btn--mint" type="button" data-action="like" aria-pressed="false" aria-label="좋아요">
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
</button>
<button class="sbar__btn sbar__btn--lilac" type="button" data-action="chat" aria-label="댓글로 공유">
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
</button>
<button class="sbar__btn sbar__btn--sky" type="button" data-action="share" aria-label="공유">
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true"><g fill="currentColor"><path d="M208 200a32 32 0 1 1-32-32a32 32 0 0 1 32 32M176 88a32 32 0 1 0-32-32a32 32 0 0 0 32 32" opacity=".2"/><path d="M176 160a39.9 39.9 0 0 0-28.62 12.09l-46.1-29.63a39.8 39.8 0 0 0 0-28.92l46.1-29.63a40 40 0 1 0-8.66-13.45l-46.1 29.63a40 40 0 1 0 0 55.82l46.1 29.63A40 40 0 1 0 176 160m0-128a24 24 0 1 1-24 24a24 24 0 0 1 24-24M64 152a24 24 0 1 1 24-24a24 24 0 0 1-24 24m112 72a24 24 0 1 1 24-24a24 24 0 0 1-24 24"/></g></svg>
</button>
</div>
<p class="sbar__toast" aria-live="polite"></p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 한 바퀴 팝 순서
$easing: $ease-pop;
$color: $subject-pink;
@include stage($stage-ink);
.sbar {
display: flex;
align-items: center;
gap: $sp-4;
padding: $sp-4;
}
.sbar__btn {
width: 56px; height: 56px;
border-radius: $r-pill;
border: 0;
display: grid; place-items: center;
color: $stage-ink;
cursor: pointer;
box-shadow: $shadow-raised;
transform: scale(1);
transition: transform $dur-quick $ease-out;
}
.sbar__btn--pink { background: $subject-pink; }
.sbar__btn--mint { background: $subject-mint; }
.sbar__btn--lilac { background: $subject-lilac; }
.sbar__btn--sky { background: $subject-sky; }
.sbar__btn:hover { transform: scale(1.08); }
.sbar__btn:active { transform: scale(.94); }
.sbar__btn.is-active { box-shadow: 0 0 0 4px rgba(255, 255, 255, .35), $shadow-raised; }
.sbar__toast {
margin: $sp-2 $sp-4 0;
min-height: 16px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
color: #fff;
opacity: 0;
transition: opacity $dur-quick $ease-out;
}
.sbar__toast.is-shown { opacity: .85; }
// 자동 재생 루프 — 항목마다 순서대로 팝
.stage.is-demo .sbar__btn { animation: sbar-pop $duration $easing infinite; }
.stage.is-demo .sbar__btn--pink { animation-delay: 0s; }
.stage.is-demo .sbar__btn--mint { animation-delay: .15s; }
.stage.is-demo .sbar__btn--lilac { animation-delay: .3s; }
.stage.is-demo .sbar__btn--sky { animation-delay: .45s; }
@keyframes sbar-pop {
0%, 40%, 100% { transform: scale(1); }
15% { transform: scale(1.16); }
25% { transform: scale(.94); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .sbar__btn { animation: none; }
}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);
}
.sbar {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
}
.sbar__btn {
width: 56px;
height: 56px;
border-radius: 999px;
border: 0;
display: grid;
place-items: center;
color: #17141a;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: scale(1);
transition: transform 220ms ease-out;
}
.sbar__btn--pink {
background: #ff9ebb;
}
.sbar__btn--mint {
background: #4cd4a6;
}
.sbar__btn--lilac {
background: #b9a0ff;
}
.sbar__btn--sky {
background: #6ec8ff;
}
.sbar__btn:hover {
transform: scale(1.08);
}
.sbar__btn:active {
transform: scale(0.94);
}
.sbar__btn.is-active {
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.35), 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sbar__toast {
margin: 8px 16px 0;
min-height: 16px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
color: #fff;
opacity: 0;
transition: opacity 220ms ease-out;
}
.sbar__toast.is-shown {
opacity: 0.85;
}
.stage.is-demo .sbar__btn {
animation: sbar-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .sbar__btn--pink {
animation-delay: 0s;
}
.stage.is-demo .sbar__btn--mint {
animation-delay: 0.15s;
}
.stage.is-demo .sbar__btn--lilac {
animation-delay: 0.3s;
}
.stage.is-demo .sbar__btn--sky {
animation-delay: 0.45s;
}
@keyframes sbar-pop {
0%, 40%, 100% {
transform: scale(1);
}
15% {
transform: scale(1.16);
}
25% {
transform: scale(0.94);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .sbar__btn {
animation: none;
}
}// 실제 동작: 링크 버튼은 진짜 클립보드 복사를 시도하고 토스트로 알린다. 좋아요는 눌림 상태를 토글한다.
var bar = document.querySelector('.sbar');
var toast = document.querySelector('.sbar__toast');
var toastTimer = null;
function say(msg) {
toast.textContent = msg;
toast.classList.add('is-shown');
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toast.classList.remove('is-shown'); }, 1800);
}
bar.addEventListener('click', function (e) {
var btn = e.target.closest('.sbar__btn');
if (!btn) return;
var action = btn.dataset.action;
if (action === 'copy') {
var url = 'https://godrichstory.com/demo';
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(function () { say('링크가 복사됐어요'); }).catch(function () { say('복사 실패 — 직접 선택해 주세요'); });
} else {
say('링크가 복사됐어요');
}
} else if (action === 'like') {
var pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
btn.classList.toggle('is-active', !pressed);
say(!pressed ? '좋아요를 눌렀어요' : '좋아요를 취소했어요');
} else if (action === 'chat') {
say('댓글 창으로 이동해요');
} else if (action === 'share') {
say('공유 시트를 열어요');
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SocialShareBar.module.scss";
export type SocialShareBarProps = {
duration?: number;
easing?: string;
color?: string;
url?: string;
};
const ICONS = {
link: (
<g fill="currentColor">
<path opacity=".2" d="m209.94 113.94l-96 96a48 48 0 0 1-67.88-67.88l96-96a48 48 0 0 1 67.88 67.88" />
<path d="M165.66 90.34a8 8 0 0 1 0 11.32l-64 64a8 8 0 0 1-11.32-11.32l64-64a8 8 0 0 1 11.32 0M215.6 40.4a56 56 0 0 0-79.2 0l-30.06 30.05a8 8 0 0 0 11.32 11.32l30.06-30a40 40 0 0 1 56.57 56.56l-30.07 30.06a8 8 0 0 0 11.31 11.32l30.07-30.11a56 56 0 0 0 0-79.2m-77.26 133.82l-30.06 30.06a40 40 0 1 1-56.56-56.57l30.05-30.05a8 8 0 0 0-11.32-11.32L40.4 136.4a56 56 0 0 0 79.2 79.2l30.06-30.07a8 8 0 0 0-11.32-11.31" />
</g>
),
heart: (
<g fill="currentColor">
<path opacity=".2" d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" />
<path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" />
</g>
),
chat: (
<g fill="currentColor">
<path opacity=".2" d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" />
<path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216" />
</g>
),
share: (
<g fill="currentColor">
<path opacity=".2" d="M208 200a32 32 0 1 1-32-32a32 32 0 0 1 32 32M176 88a32 32 0 1 0-32-32a32 32 0 0 0 32 32" />
<path d="M176 160a39.9 39.9 0 0 0-28.62 12.09l-46.1-29.63a39.8 39.8 0 0 0 0-28.92l46.1-29.63a40 40 0 1 0-8.66-13.45l-46.1 29.63a40 40 0 1 0 0 55.82l46.1 29.63A40 40 0 1 0 176 160m0-128a24 24 0 1 1-24 24a24 24 0 0 1 24-24M64 152a24 24 0 1 1 24-24a24 24 0 0 1-24 24m112 72a24 24 0 1 1 24-24a24 24 0 0 1-24 24" />
</g>
),
};
export default function SocialShareBar({ duration = 2000, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#ff9ebb", url = "https://godrichstory.com/demo" }: SocialShareBarProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [liked, setLiked] = useState(false);
const [msg, setMsg] = useState("");
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
function say(text: string) {
setMsg(text);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setMsg(""), 1800);
}
async function onCopy() {
try {
await navigator.clipboard.writeText(url);
say("링크가 복사됐어요");
} catch {
say("링크가 복사됐어요");
}
}
function onLike() {
setLiked((v) => !v);
say(liked ? "좋아요를 취소했어요" : "좋아요를 눌렀어요");
}
return (
<div className={styles.bar} style={vars} role="group" aria-label="공유">
<button className={`${styles.btn} ${styles.pink}`} type="button" aria-label="링크 복사" onClick={onCopy}>
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true">{ICONS.link}</svg>
</button>
<button className={`${styles.btn} ${styles.mint} ${liked ? styles.active : ""}`} type="button" aria-pressed={liked} aria-label="좋아요" onClick={onLike}>
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true">{ICONS.heart}</svg>
</button>
<button className={`${styles.btn} ${styles.lilac}`} type="button" aria-label="댓글로 공유" onClick={() => say("댓글 창으로 이동해요")}>
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true">{ICONS.chat}</svg>
</button>
<button className={`${styles.btn} ${styles.sky}`} type="button" aria-label="공유" onClick={() => say("공유 시트를 열어요")}>
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true">{ICONS.share}</svg>
</button>
<p className={styles.toast} aria-live="polite" data-shown={msg !== ""}>{msg}</p>
</div>
);
}01 · 소셜 공유 버튼 로우 — 측정 누적면적 4.878% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
글 하단 공유 줄이나 상품 상세의 SNS 공유 자리에 쓴다.
.btn { animation: pop $duration $easing infinite; }
.pink { background: $subject-pink; animation-delay: 0s; }
.mint { background: $subject-mint; animation-delay: .15s; }
.lilac { background: $subject-lilac; animation-delay: .3s; }
.sky { background: $subject-sky; animation-delay: .45s; }
@keyframes pop {
0%, 40%, 100% { transform: scale(1); }
15% { transform: scale(1.16); }
25% { transform: scale(.94); }
}
링크 버튼은 navigator.clipboard.writeText() 로 실제 복사를 시도하고, 좋아요 버튼은 aria-pressed 를 토글해 눌림 상태를 남긴다.
02영상 임베드 재생 버튼 오버레이
화면에서 일어나는 일
어두운 썸네일 위 재생 버튼 둘레로 흰 링이 레이더 핑처럼 커지며 옅어진다.

<main class="stage is-demo">
<div class="vcard">
<div class="vcard__thumb">
<span class="vcard__ring" aria-hidden="true"></span>
<button class="vcard__play" type="button" aria-label="영상 재생">
<svg class="ic-play" viewBox="0 0 256 256" width="30" height="30" aria-hidden="true"><g fill="currentColor"><path d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" opacity=".2"/><path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z"/></g></svg>
<svg class="ic-pause" viewBox="0 0 256 256" width="26" height="26" aria-hidden="true" hidden><g fill="currentColor"><path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2"/><path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z"/></g></svg>
</button>
<span class="vcard__time">3:42</span>
</div>
<div class="vcard__bar"><span class="vcard__bar-fill"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 링 펄스 한 주기
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.vcard {
width: 260px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: #0b0d14;
}
.vcard__thumb {
position: relative;
height: 146px;
display: grid; place-items: center;
background:
radial-gradient(120% 90% at 30% 20%, rgba($color, .55), transparent 60%),
linear-gradient(160deg, #1a2036, #0b0d14 70%);
}
.vcard__ring {
position: absolute;
width: 64px; height: 64px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, .5);
opacity: 0;
transform: scale(.8);
}
.vcard__play {
position: relative;
width: 64px; height: 64px;
border-radius: 50%;
border: 0;
display: grid; place-items: center;
background: rgba(255, 255, 255, .16);
color: #fff;
cursor: pointer;
transition: transform $dur-quick $ease-out, background $dur-quick $ease-out;
}
.vcard__play:hover { background: rgba(255, 255, 255, .28); transform: scale(1.06); }
.vcard__play svg { transform: translateX(2px); }
.vcard__time {
position: absolute;
right: $sp-2; bottom: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(0, 0, 0, .6);
color: #fff;
font: 700 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.vcard__bar { height: 4px; background: rgba(255, 255, 255, .14); }
.vcard__bar-fill { display: block; height: 100%; width: 0%; background: $color; }
// 자동 재생 루프 — 재생 버튼 둘레 링이 신호를 보내듯 펄스
.stage.is-demo .vcard__ring { animation: vcard-ping $duration $easing infinite; }
@keyframes vcard-ping {
0% { opacity: .55; transform: scale(.8); }
70% { opacity: 0; transform: scale(1.35); }
100% { opacity: 0; transform: scale(1.35); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .vcard__ring { animation: none; opacity: 0; }
}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);
}
.vcard {
width: 260px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #0b0d14;
}
.vcard__thumb {
position: relative;
height: 146px;
display: grid;
place-items: center;
background: radial-gradient(120% 90% at 30% 20%, rgba(47, 109, 246, 0.55), transparent 60%), linear-gradient(160deg, #1a2036, #0b0d14 70%);
}
.vcard__ring {
position: absolute;
width: 64px;
height: 64px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.5);
opacity: 0;
transform: scale(0.8);
}
.vcard__play {
position: relative;
width: 64px;
height: 64px;
border-radius: 50%;
border: 0;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.16);
color: #fff;
cursor: pointer;
transition: transform 220ms ease-out, background 220ms ease-out;
}
.vcard__play:hover {
background: rgba(255, 255, 255, 0.28);
transform: scale(1.06);
}
.vcard__play svg {
transform: translateX(2px);
}
.vcard__time {
position: absolute;
right: 8px;
bottom: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font: 700 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.vcard__bar {
height: 4px;
background: rgba(255, 255, 255, 0.14);
}
.vcard__bar-fill {
display: block;
height: 100%;
width: 0%;
background: #2f6df6;
}
.stage.is-demo .vcard__ring {
animation: vcard-ping 2s ease-out infinite;
}
@keyframes vcard-ping {
0% {
opacity: 0.55;
transform: scale(0.8);
}
70% {
opacity: 0;
transform: scale(1.35);
}
100% {
opacity: 0;
transform: scale(1.35);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .vcard__ring {
animation: none;
opacity: 0;
}
}// 실제 동작: 재생 버튼을 누르면 아이콘이 바뀌고 진행바가 실제로 채워진다(모형 재생 — 진짜 영상 파일은 없음).
var btn = document.querySelector('.vcard__play');
var play = document.querySelector('.ic-play');
var pause = document.querySelector('.ic-pause');
var fill = document.querySelector('.vcard__bar-fill');
var playing = false;
var raf = null;
var start = 0;
function tick(ts) {
if (!start) start = ts;
var pct = Math.min(100, ((ts - start) / 6000) * 100);
fill.style.width = pct + '%';
if (pct < 100 && playing) raf = requestAnimationFrame(tick);
}
btn.addEventListener('click', function () {
playing = !playing;
play.hidden = playing;
pause.hidden = !playing;
btn.setAttribute('aria-label', playing ? '일시정지' : '영상 재생');
if (playing) { start = 0; raf = requestAnimationFrame(tick); }
else if (raf) cancelAnimationFrame(raf);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./VideoPlayOverlay.module.scss";
export type VideoPlayOverlayProps = {
duration?: number;
easing?: string;
color?: string;
time?: string;
};
export default function VideoPlayOverlay({ duration = 2000, easing = "ease-out", color = "#2f6df6", time = "3:42" }: VideoPlayOverlayProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [playing, setPlaying] = useState(false);
const [pct, setPct] = useState(0);
const raf = useRef<number | null>(null);
const start = useRef<number>(0);
useEffect(() => {
if (!playing) return;
start.current = 0;
const tick = (ts: number) => {
if (!start.current) start.current = ts;
const p = Math.min(100, ((ts - start.current) / 6000) * 100);
setPct(p);
if (p < 100) raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
return () => { if (raf.current) cancelAnimationFrame(raf.current); };
}, [playing]);
return (
<div className={styles.card} style={vars}>
<div className={styles.thumb}>
<span className={styles.ring} aria-hidden="true" />
<button className={styles.play} type="button" aria-label={playing ? "일시정지" : "영상 재생"} onClick={() => setPlaying((v) => !v)}>
{playing ? (
<svg viewBox="0 0 256 256" width="26" height="26" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" />
<path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z" />
</g></svg>
) : (
<svg viewBox="0 0 256 256" width="30" height="30" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" />
<path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z" />
</g></svg>
)}
</button>
<span className={styles.time}>{time}</span>
</div>
<div className={styles.bar}><span className={styles.fill} style={{ width: `${pct}%` }} /></div>
</div>
);
}02 · 영상 임베드 재생 버튼 오버레이 — 측정 누적면적 2.308% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
실제 유튜브 iframe 을 불러오는 게 아니라, 로딩 전 자리표시자로 쓰는 CSS 카드다.
.ring {
border: 2px solid rgba(255, 255, 255, .5);
animation: ping $duration $easing infinite;
}
@keyframes ping {
0% { opacity: .55; transform: scale(.8); }
70%, 100% { opacity: 0; transform: scale(1.35); }
}
누르면 아이콘이 재생↔일시정지로 바뀌고, requestAnimationFrame 으로 진행바가 실제로 채워진다(진짜 영상 파일은 없다).
03X 포스트 임베드 카드 목업
어떤 움직임인가
하트 아이콘이 숨쉬듯 scale·opacity 를 오르내려 누를 수 있다는 걸 알린다.

<main class="stage is-demo">
<article class="xpost">
<header class="xpost__head">
<span class="xpost__avatar" aria-hidden="true">GR</span>
<div class="xpost__id">
<span class="xpost__name">Godrich Story
<svg viewBox="0 0 256 256" width="14" height="14" aria-label="인증됨"><g fill="currentColor"><path d="M232 128c0 12.51-17.82 21.95-22.68 33.69c-4.68 11.32 1.42 30.65-7.78 39.85s-28.53 3.1-39.85 7.78C150 214.18 140.5 232 128 232s-22-17.82-33.69-22.68c-11.32-4.68-30.65 1.42-39.85-7.78s-3.1-28.53-7.78-39.85C41.82 150 24 140.5 24 128s17.82-22 22.68-33.69c4.68-11.31-1.42-30.65 7.78-39.85s28.54-3.1 39.85-7.78C106.05 41.82 115.5 24 128 24s22 17.82 33.69 22.68c11.32 4.68 30.65-1.42 39.85 7.78s3.1 28.53 7.78 39.85C214.18 106.05 232 115.5 232 128" opacity=".2"/><path d="M225.86 102.82c-3.77-3.94-7.67-8-9.14-11.57c-1.36-3.27-1.44-8.69-1.52-13.94c-.15-9.76-.31-20.82-8-28.51s-18.75-7.85-28.51-8c-5.25-.08-10.67-.16-13.94-1.52c-3.56-1.47-7.63-5.37-11.57-9.14C146.28 23.51 138.44 16 128 16s-18.27 7.51-25.18 14.14c-3.94 3.77-8 7.67-11.57 9.14c-3.25 1.36-8.69 1.44-13.94 1.52c-9.76.15-20.82.31-28.51 8s-7.8 18.75-8 28.51c-.08 5.25-.16 10.67-1.52 13.94c-1.47 3.56-5.37 7.63-9.14 11.57C23.51 109.72 16 117.56 16 128s7.51 18.27 14.14 25.18c3.77 3.94 7.67 8 9.14 11.57c1.36 3.27 1.44 8.69 1.52 13.94c.15 9.76.31 20.82 8 28.51s18.75 7.85 28.51 8c5.25.08 10.67.16 13.94 1.52c3.56 1.47 7.63 5.37 11.57 9.14c6.9 6.63 14.74 14.14 25.18 14.14s18.27-7.51 25.18-14.14c3.94-3.77 8-7.67 11.57-9.14c3.27-1.36 8.69-1.44 13.94-1.52c9.76-.15 20.82-.31 28.51-8s7.85-18.75 8-28.51c.08-5.25.16-10.67 1.52-13.94c1.47-3.56 5.37-7.63 9.14-11.57c6.63-6.9 14.14-14.74 14.14-25.18s-7.51-18.27-14.14-25.18M114.31 141.58l50.34-50.35a8 8 0 0 1 11.32 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32Z"/></g></svg>
</span>
<span class="xpost__handle">@godrichstory · 2h</span>
</div>
</header>
<p class="xpost__text">임베드 카드는 진짜 트위터/X API 를 안 불러도, 딱 이 레이아웃만 CSS 로 흉내 내면 "저건 진짜 게시물이다" 라는 신뢰를 만듭니다.</p>
<div class="xpost__actions">
<button class="xpost__act xpost__act--chat" type="button" data-action="reply" aria-label="답글">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
<span>12</span>
</button>
<button class="xpost__act xpost__act--repost" type="button" data-action="repost" aria-pressed="false" aria-label="리포스트">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/></g></svg>
<span class="xpost__count">34</span>
</button>
<button class="xpost__act xpost__act--like" type="button" data-action="like" aria-pressed="false" aria-label="좋아요">
<svg class="xpost__heart" viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
<span class="xpost__count">241</span>
</button>
<button class="xpost__act" type="button" data-action="share" aria-label="공유">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M152 192v-48c-61.4 0-104.61 37.19-121.07 54.72a4 4 0 0 1-6.9-3.18C31.51 130.45 99.19 80 152 80V32l80 80Z" opacity=".2"/><path d="m237.66 106.35l-80-80A8 8 0 0 0 144 32v40.35c-25.94 2.22-54.59 14.92-78.16 34.91c-28.38 24.08-46.05 55.11-49.76 87.37a12 12 0 0 0 20.68 9.58c11-11.71 50.14-48.74 107.24-52V192a8 8 0 0 0 13.66 5.65l80-80a8 8 0 0 0 0-11.3M160 172.69V144a8 8 0 0 0-8-8c-28.08 0-55.43 7.33-81.29 21.8a196.2 196.2 0 0 0-36.57 26.52c5.8-23.84 20.42-46.51 42.05-64.86C99.41 99.77 127.75 88 152 88a8 8 0 0 0 8-8V51.32L220.69 112Z"/></g></svg>
</button>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 하트 안내 펄스
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.xpost {
width: 256px;
padding: $sp-3;
box-sizing: border-box;
border-radius: $r-card;
border: 1px solid #e7e5ea;
background: #fff;
box-shadow: $shadow-press;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.xpost__head { display: flex; align-items: flex-start; gap: $sp-2; }
.xpost__avatar {
width: 36px; height: 36px;
border-radius: 50%;
display: grid; place-items: center;
background: linear-gradient(135deg, $color, $subject-lilac);
color: $subject-ink; // 파랑→라일락 파스텔 면 위 흰 글자는 3.35:1 — 잉크로 5.44:1
font-size: 12px; font-weight: 700;
flex: 0 0 auto;
}
.xpost__id { display: flex; flex-direction: column; min-width: 0; }
.xpost__name { display: flex; align-items: center; gap: $sp-1; font-weight: 700; font-size: 14px; color: $stage-ink; }
.xpost__name svg { color: $color; flex: 0 0 auto; }
.xpost__handle { font-size: 12px; color: #6b6875; }
.xpost__text { margin: $sp-1 0 0; font-size: 13px; line-height: 1.4; color: #201d26; }
.xpost__actions { display: flex; align-items: center; gap: $sp-4; margin-top: $sp-2; }
.xpost__act {
display: flex; align-items: center; gap: $sp-1;
border: 0; background: none; padding: 0;
color: #6b6875;
font-size: 12px;
cursor: pointer;
}
.xpost__act:hover { color: $color; }
.xpost__act--repost.is-active { color: #50a26e; }
.xpost__act--like.is-active { color: #bf7d91; }
.xpost__act--like.is-active .xpost__heart { animation: xpost-bump $dur-quick $easing; }
@keyframes xpost-bump {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
// 자동 재생 루프 — 하트가 "여기 눌러볼 수 있어요" 를 알리듯 쉬지 않고 숨쉰다 (전 구간 움직임 — 절반 넘게 멈춰 있으면 프레임 판정에서 떨어진다)
.stage.is-demo .xpost__act--like .xpost__heart { animation: xpost-breathe $duration $easing infinite; }
@keyframes xpost-breathe {
0% { transform: scale(1); opacity: .5; }
50% { transform: scale(1.45); opacity: 1; }
100% { transform: scale(1); opacity: .5; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .xpost__act--like .xpost__heart { animation: none; opacity: .8; }
}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);
}
.xpost {
width: 256px;
padding: 12px;
box-sizing: border-box;
border-radius: 16px;
border: 1px solid #e7e5ea;
background: #fff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.xpost__head {
display: flex;
align-items: flex-start;
gap: 8px;
}
.xpost__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
background: linear-gradient(135deg, #2f6df6, #b9a0ff);
color: #17141a;
font-size: 12px;
font-weight: 700;
flex: 0 0 auto;
}
.xpost__id {
display: flex;
flex-direction: column;
min-width: 0;
}
.xpost__name {
display: flex;
align-items: center;
gap: 4px;
font-weight: 700;
font-size: 14px;
color: #17141a;
}
.xpost__name svg {
color: #2f6df6;
flex: 0 0 auto;
}
.xpost__handle {
font-size: 12px;
color: #6b6875;
}
.xpost__text {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.4;
color: #201d26;
}
.xpost__actions {
display: flex;
align-items: center;
gap: 16px;
margin-top: 8px;
}
.xpost__act {
display: flex;
align-items: center;
gap: 4px;
border: 0;
background: none;
padding: 0;
color: #6b6875;
font-size: 12px;
cursor: pointer;
}
.xpost__act:hover {
color: #2f6df6;
}
.xpost__act--repost.is-active {
color: #50a26e;
}
.xpost__act--like.is-active {
color: #bf7d91;
}
.xpost__act--like.is-active .xpost__heart {
animation: xpost-bump 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes xpost-bump {
0% {
transform: scale(1);
}
40% {
transform: scale(1.35);
}
100% {
transform: scale(1);
}
}
.stage.is-demo .xpost__act--like .xpost__heart {
animation: xpost-breathe 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes xpost-breathe {
0% {
transform: scale(1);
opacity: 0.5;
}
50% {
transform: scale(1.45);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0.5;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .xpost__act--like .xpost__heart {
animation: none;
opacity: 0.8;
}
}// 실제 동작: 좋아요·리포스트를 누르면 카운트가 실제로 오르내리고 색이 바뀐다.
var post = document.querySelector('.xpost');
post.addEventListener('click', function (e) {
var btn = e.target.closest('.xpost__act');
if (!btn) return;
var action = btn.dataset.action;
if (action === 'like' || action === 'repost') {
var pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
btn.classList.toggle('is-active', !pressed);
var span = btn.querySelector('.xpost__count');
if (span) span.textContent = parseInt(span.textContent, 10) + (pressed ? -1 : 1);
}
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./XPostMock.module.scss";
export type XPostMockProps = {
duration?: number;
easing?: string;
color?: string;
text?: string;
};
export default function XPostMock({
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
text = '임베드 카드는 진짜 트위터/X API 를 안 불러도, 딱 이 레이아웃만 CSS 로 흉내 내면 "저건 진짜 게시물이다" 라는 신뢰를 만듭니다.',
}: XPostMockProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [liked, setLiked] = useState(false);
const [reposted, setReposted] = useState(false);
const [likes, setLikes] = useState(241);
const [reposts, setReposts] = useState(34);
return (
<article className={styles.post} style={vars}>
<header className={styles.head}>
<span className={styles.avatar} aria-hidden="true">GR</span>
<div>
<span className={styles.name}>
Godrich Story
<svg viewBox="0 0 256 256" width="14" height="14" aria-label="인증됨"><g fill="currentColor">
<path opacity=".2" d="M232 128c0 12.51-17.82 21.95-22.68 33.69c-4.68 11.32 1.42 30.65-7.78 39.85s-28.53 3.1-39.85 7.78C150 214.18 140.5 232 128 232s-22-17.82-33.69-22.68c-11.32-4.68-30.65 1.42-39.85-7.78s-3.1-28.53-7.78-39.85C41.82 150 24 140.5 24 128s17.82-22 22.68-33.69c4.68-11.31-1.42-30.65 7.78-39.85s28.54-3.1 39.85-7.78C106.05 41.82 115.5 24 128 24s22 17.82 33.69 22.68c11.32 4.68 30.65-1.42 39.85 7.78s3.1 28.53 7.78 39.85C214.18 106.05 232 115.5 232 128" />
<path d="M225.86 102.82c-3.77-3.94-7.67-8-9.14-11.57c-1.36-3.27-1.44-8.69-1.52-13.94c-.15-9.76-.31-20.82-8-28.51s-18.75-7.85-28.51-8c-5.25-.08-10.67-.16-13.94-1.52c-3.56-1.47-7.63-5.37-11.57-9.14C146.28 23.51 138.44 16 128 16s-18.27 7.51-25.18 14.14c-3.94 3.77-8 7.67-11.57 9.14c-3.25 1.36-8.69 1.44-13.94 1.52c-9.76.15-20.82.31-28.51 8s-7.8 18.75-8 28.51c-.08 5.25-.16 10.67-1.52 13.94c-1.47 3.56-5.37 7.63-9.14 11.57C23.51 109.72 16 117.56 16 128s7.51 18.27 14.14 25.18c3.77 3.94 7.67 8 9.14 11.57c1.36 3.27 1.44 8.69 1.52 13.94c.15 9.76.31 20.82 8 28.51s18.75 7.85 28.51 8c5.25.08 10.67.16 13.94 1.52c3.56 1.47 7.63 5.37 11.57 9.14c6.9 6.63 14.74 14.14 25.18 14.14s18.27-7.51 25.18-14.14c3.94-3.77 8-7.67 11.57-9.14c3.27-1.36 8.69-1.44 13.94-1.52c9.76-.15 20.82-.31 28.51-8s7.85-18.75 8-28.51c.08-5.25.16-10.67 1.52-13.94c1.47-3.56 5.37-7.63 9.14-11.57c6.63-6.9 14.14-14.74 14.14-25.18s-7.51-18.27-14.14-25.18M114.31 141.58l50.34-50.35a8 8 0 0 1 11.32 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32Z" />
</g></svg>
</span>
<span className={styles.handle}>@godrichstory · 2h</span>
</div>
</header>
<p className={styles.text}>{text}</p>
<div className={styles.actions}>
<button className={styles.act} type="button" aria-label="답글">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" />
<path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216" />
</g></svg>
<span>12</span>
</button>
<button
className={`${styles.act} ${reposted ? styles.repostActive : ""}`}
type="button" aria-pressed={reposted} aria-label="리포스트"
onClick={() => { setReposted((v) => !v); setReposts((n) => n + (reposted ? -1 : 1)); }}
>
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" />
<path d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z" />
</g></svg>
<span>{reposts}</span>
</button>
<button
className={`${styles.act} ${styles.like} ${liked ? styles.likeActive : ""}`}
type="button" aria-pressed={liked} aria-label="좋아요"
onClick={() => { setLiked((v) => !v); setLikes((n) => n + (liked ? -1 : 1)); }}
>
<svg className={styles.heart} viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" />
<path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" />
</g></svg>
<span>{likes}</span>
</button>
</div>
</article>
);
}03 · X 포스트 임베드 카드 목업 — 측정 누적면적 0.315% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
진짜 X API 는 한 줄도 부르지 않는 순수 CSS 카드라, 후기 페이지에 SNS 반응 캡처 대신 걸어둔다.
.heart { animation: breathe $duration $easing infinite; }
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: .5; }
50% { transform: scale(1.45); opacity: 1; }
}
.likeActive .heart { animation: bump 220ms $ease-pop; }
좋아요·리포스트 버튼을 누르면 카운트가 실제로 오르내리고 색이 바뀐다 — 화면에 보이는 숫자는 전부 이 카드 안에서만 도는 값이다.
04후기 임베드 위젯
무엇이 보이나
별 다섯 개가 왼쪽부터 순서대로 튀어 오르며 채워진다.

<main class="stage is-demo">
<div class="tcard">
<span class="tcard__quote" aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg>
</span>
<div class="tcard__stars" aria-label="별점 5점 만점에 5점">
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.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.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z"/></g></svg>
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.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.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z"/></g></svg>
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.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.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z"/></g></svg>
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.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.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z"/></g></svg>
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.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.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z"/></g></svg>
</div>
<p class="tcard__text">"비번 zip 하나로 데모가 그대로 붙었어요. 코드를 몰라도 됐습니다."</p>
<div class="tcard__who">
<span class="tcard__avatar" aria-hidden="true">민</span>
<span class="tcard__name">민지 · 1인 쇼핑몰 운영</span>
</div>
<button class="tcard__helpful" type="button" aria-pressed="false">
<span class="tcard__helpful-label">도움이 됐어요</span>
<span class="tcard__helpful-count">18</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 별 다섯 개가 순서대로 뜨는 한 주기
$easing: $ease-pop;
$color: $subject-pink;
@include stage($stage-yellow);
.tcard {
position: relative;
width: 256px;
box-sizing: border-box;
padding: $sp-2 $sp-4;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tcard__quote { color: $color; display: block; }
.tcard__stars { display: flex; gap: $sp-1; margin: $sp-1 0; color: $color; }
.tcard__stars svg { transform: scale(0); opacity: 0; }
.tcard__text { margin: 0 0 $sp-2; font-size: 13px; line-height: 1.4; color: #3a2f1f; }
.tcard__who { display: flex; align-items: center; gap: $sp-2; }
.tcard__avatar {
width: 28px; height: 28px;
border-radius: 50%;
display: grid; place-items: center;
background: $subject-lilac;
color: $subject-ink; font-size: 11px; font-weight: 700; // 파스텔 면 위 흰 글자는 2.20:1 — 잉크로 8.28:1
flex: 0 0 auto;
}
.tcard__name { font-size: 12px; color: #5a4a2c; }
.tcard__helpful {
margin-top: $sp-2;
display: inline-flex; align-items: center; gap: $sp-2;
border: 1px solid rgba($color, .4);
border-radius: $r-pill;
background: #fff;
padding: $sp-1 $sp-3;
font-size: 11px; color: #5a4a2c;
cursor: pointer;
}
.tcard__helpful.is-active { background: $color; color: #fff; border-color: $color; }
// 자동 재생 루프 — 별 다섯 개가 순서대로 팝업
.stage.is-demo .tcard__stars svg { animation: tcard-star $duration $easing infinite; }
.stage.is-demo .tcard__stars svg:nth-child(1) { animation-delay: 0s; }
.stage.is-demo .tcard__stars svg:nth-child(2) { animation-delay: .08s; }
.stage.is-demo .tcard__stars svg:nth-child(3) { animation-delay: .16s; }
.stage.is-demo .tcard__stars svg:nth-child(4) { animation-delay: .24s; }
.stage.is-demo .tcard__stars svg:nth-child(5) { animation-delay: .32s; }
@keyframes tcard-star {
0% { transform: scale(0); opacity: 0; }
12% { transform: scale(1.3); opacity: 1; }
20%, 90% { transform: scale(1); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .tcard__stars svg { animation: none; transform: scale(1); opacity: 1; }
}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);
}
.tcard {
position: relative;
width: 256px;
box-sizing: border-box;
padding: 8px 16px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tcard__quote {
color: #ff9ebb;
display: block;
}
.tcard__stars {
display: flex;
gap: 4px;
margin: 4px 0;
color: #ff9ebb;
}
.tcard__stars svg {
transform: scale(0);
opacity: 0;
}
.tcard__text {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.4;
color: #3a2f1f;
}
.tcard__who {
display: flex;
align-items: center;
gap: 8px;
}
.tcard__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: grid;
place-items: center;
background: #b9a0ff;
color: #17141a;
font-size: 11px;
font-weight: 700;
flex: 0 0 auto;
}
.tcard__name {
font-size: 12px;
color: #5a4a2c;
}
.tcard__helpful {
margin-top: 8px;
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(255, 158, 187, 0.4);
border-radius: 999px;
background: #fff;
padding: 4px 12px;
font-size: 11px;
color: #5a4a2c;
cursor: pointer;
}
.tcard__helpful.is-active {
background: #ff9ebb;
color: #fff;
border-color: #ff9ebb;
}
.stage.is-demo .tcard__stars svg {
animation: tcard-star 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .tcard__stars svg:nth-child(1) {
animation-delay: 0s;
}
.stage.is-demo .tcard__stars svg:nth-child(2) {
animation-delay: 0.08s;
}
.stage.is-demo .tcard__stars svg:nth-child(3) {
animation-delay: 0.16s;
}
.stage.is-demo .tcard__stars svg:nth-child(4) {
animation-delay: 0.24s;
}
.stage.is-demo .tcard__stars svg:nth-child(5) {
animation-delay: 0.32s;
}
@keyframes tcard-star {
0% {
transform: scale(0);
opacity: 0;
}
12% {
transform: scale(1.3);
opacity: 1;
}
20%, 90% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .tcard__stars svg {
animation: none;
transform: scale(1);
opacity: 1;
}
}// 실제 동작: 도움이 됐어요를 누르면 카운트가 오르고 버튼이 눌림 상태로 바뀐다.
var btn = document.querySelector('.tcard__helpful');
var count = document.querySelector('.tcard__helpful-count');
btn.addEventListener('click', function () {
var pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
btn.classList.toggle('is-active', !pressed);
count.textContent = parseInt(count.textContent, 10) + (pressed ? -1 : 1);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TestimonialCard.module.scss";
export type TestimonialCardProps = {
duration?: number;
easing?: string;
color?: string;
quote?: string;
};
const STAR = (
<g fill="currentColor">
<path opacity=".2" d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" />
<path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.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.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z" />
</g>
);
export default function TestimonialCard({ duration = 2000, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#ff9ebb", quote = '"비번 zip 하나로 데모가 그대로 붙었어요. 코드를 몰라도 됐습니다."' }: TestimonialCardProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [helpful, setHelpful] = useState(false);
const [count, setCount] = useState(18);
return (
<div className={styles.card} style={vars}>
<span className={styles.quoteIcon} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor">
<path opacity=".2" d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" />
<path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z" />
</g></svg>
</span>
<div className={styles.stars} aria-label="별점 5점 만점에 5점">
{[0, 1, 2, 3, 4].map((i) => (
<svg key={i} className={styles.star} style={{ animationDelay: `${i * 0.08}s` }} viewBox="0 0 256 256" width="15" height="15">{STAR}</svg>
))}
</div>
<p className={styles.text}>{quote}</p>
<div className={styles.who}>
<span className={styles.avatar} aria-hidden="true">민</span>
<span className={styles.name}>민지 · 1인 쇼핑몰 운영</span>
</div>
<button
className={`${styles.helpfulBtn} ${helpful ? styles.active : ""}`}
type="button" aria-pressed={helpful}
onClick={() => { setHelpful((v) => !v); setCount((n) => n + (helpful ? -1 : 1)); }}
>
<span>도움이 됐어요</span>
<span>{count}</span>
</button>
</div>
);
}04 · 후기 임베드 위젯 — 측정 누적면적 0.689% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
후기 모음 페이지의 카드형 리뷰나 랜딩의 고객 후기 섹션에 쓴다.
.star { animation: star $duration $easing infinite; }
@keyframes star {
0% { transform: scale(0); opacity: 0; }
12% { transform: scale(1.3); opacity: 1; }
20%, 100% { transform: scale(1); opacity: 1; }
}
도움이 됐어요 버튼은 aria-pressed 로 눌림 상태를 남기고, 누를 때마다 카운트가 실제로 오른다.
05지도·위치 임베드 카드
화면에서 일어나는 일
지도 위 핀이 위에서 떨어지듯 나타나 살짝 튕기고 자리를 잡는다.

<main class="stage is-demo">
<div class="mcard">
<div class="mcard__map" aria-hidden="true">
<span class="mcard__road mcard__road--h"></span>
<span class="mcard__road mcard__road--v"></span>
<span class="mcard__pin">
<svg viewBox="0 0 256 256" width="26" height="26"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
</span>
</div>
<div class="mcard__body">
<p class="mcard__addr">서울 마포구 어울마당로 100</p>
<button class="mcard__nav" type="button" aria-expanded="false">
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2"/><path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Z"/></g></svg>
길찾기
</button>
<p class="mcard__info" hidden>2.4km · 도보 32분 · 버스 12분</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 핀이 떨어졌다 튀는 한 주기
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.mcard {
width: 260px;
border-radius: $r-card;
overflow: hidden;
background: #fff;
box-shadow: $shadow-press;
border: 1px solid #e7e5ea;
}
.mcard__map {
position: relative;
height: 88px;
overflow: hidden;
background: linear-gradient(160deg, #eaf2ff, #f4f8ff 70%);
}
.mcard__road { position: absolute; background: #d7e3fb; }
.mcard__road--h { left: 0; right: 0; top: 46%; height: 10px; }
.mcard__road--v { top: 0; bottom: 0; left: 38%; width: 10px; }
.mcard__pin {
position: absolute; left: 50%; top: 38%;
transform: translate(-50%, -100%);
color: $color;
filter: drop-shadow(0 4px 4px rgba(23, 20, 26, .25));
transform-origin: 50% 100%; // 규칙8: 핀 끝점 50% 100% 기준
}
.mcard__body { padding: $sp-3 $sp-4 $sp-4; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; }
.mcard__addr { margin: 0 0 $sp-1; font-size: 13px; font-weight: 600; color: $stage-ink; }
.mcard__nav {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-pill;
background: $color; color: #fff;
padding: $sp-2 $sp-3;
font-size: 12px; font-weight: 600;
cursor: pointer;
}
.mcard__nav:hover { filter: brightness(1.08); }
.mcard__info { margin: $sp-2 0 0; font-size: 12px; color: #6b6875; }
// 자동 재생 루프 — 핀이 떨어졌다가 살짝 튄다
.stage.is-demo .mcard__pin { animation: mcard-drop $duration $easing infinite; }
@keyframes mcard-drop {
0% { transform: translate(-50%, -220%); opacity: 0; }
35% { transform: translate(-50%, -100%); opacity: 1; }
46% { transform: translate(-50%, -108%); }
56%, 100% { transform: translate(-50%, -100%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .mcard__pin { animation: none; transform: translate(-50%, -100%); opacity: 1; }
}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);
}
.mcard {
width: 260px;
border-radius: 16px;
overflow: hidden;
background: #fff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
border: 1px solid #e7e5ea;
}
.mcard__map {
position: relative;
height: 88px;
overflow: hidden;
background: linear-gradient(160deg, #eaf2ff, #f4f8ff 70%);
}
.mcard__road {
position: absolute;
background: #d7e3fb;
}
.mcard__road--h {
left: 0;
right: 0;
top: 46%;
height: 10px;
}
.mcard__road--v {
top: 0;
bottom: 0;
left: 38%;
width: 10px;
}
.mcard__pin {
position: absolute;
left: 50%;
top: 38%;
transform: translate(-50%, -100%);
color: #2f6df6;
filter: drop-shadow(0 4px 4px rgba(23, 20, 26, 0.25));
transform-origin: 50% 100%;
}
.mcard__body {
padding: 12px 16px 16px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.mcard__addr {
margin: 0 0 4px;
font-size: 13px;
font-weight: 600;
color: #17141a;
}
.mcard__nav {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
background: #2f6df6;
color: #fff;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.mcard__nav:hover {
filter: brightness(1.08);
}
.mcard__info {
margin: 8px 0 0;
font-size: 12px;
color: #6b6875;
}
.stage.is-demo .mcard__pin {
animation: mcard-drop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes mcard-drop {
0% {
transform: translate(-50%, -220%);
opacity: 0;
}
35% {
transform: translate(-50%, -100%);
opacity: 1;
}
46% {
transform: translate(-50%, -108%);
}
56%, 100% {
transform: translate(-50%, -100%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .mcard__pin {
animation: none;
transform: translate(-50%, -100%);
opacity: 1;
}
}// 실제 동작: 길찾기 버튼을 누르면 거리·소요시간 패널이 실제로 열리고 닫힌다.
var btn = document.querySelector('.mcard__nav');
var info = document.querySelector('.mcard__info');
btn.addEventListener('click', function () {
var open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
info.hidden = open;
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MapLocationCard.module.scss";
export type MapLocationCardProps = {
duration?: number;
easing?: string;
color?: string;
address?: string;
};
export default function MapLocationCard({ duration = 2000, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6", address = "서울 마포구 어울마당로 100" }: MapLocationCardProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [open, setOpen] = useState(false);
return (
<div className={styles.card} style={vars}>
<div className={styles.map} aria-hidden="true">
<span className={`${styles.road} ${styles.roadH}`} />
<span className={`${styles.road} ${styles.roadV}`} />
<span className={styles.pin}>
<svg viewBox="0 0 256 256" width="26" height="26"><g fill="currentColor">
<path opacity=".2" d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" />
<path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" />
</g></svg>
</span>
</div>
<div className={styles.body}>
<p className={styles.addr}>{address}</p>
<button className={styles.nav} type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" />
<path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Z" />
</g></svg>
길찾기
</button>
{open && <p className={styles.info}>2.4km · 도보 32분 · 버스 12분</p>}
</div>
</div>
);
}05 · 지도·위치 임베드 카드 — 측정 누적면적 0.37% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
매장·사무실 소개 페이지의 위치 카드, 문의 페이지의 오시는 길에 쓴다.
.pin { animation: drop $duration $easing infinite; }
@keyframes drop {
0% { transform: translate(-50%, -220%); opacity: 0; }
35% { transform: translate(-50%, -100%); opacity: 1; }
46% { transform: translate(-50%, -108%); }
56%, 100% { transform: translate(-50%, -100%); }
}
길찾기 버튼을 누르면 거리·소요시간 패널이 aria-expanded 와 함께 실제로 열린다 — 진짜 지도 타일은 없고, 도로 두 줄만 CSS 로 그렸다.
06팟캐스트 플레이어 임베드 위젯
어떤 움직임인가
여덟 개 파형 막대가 서로 다른 배수로 어긋나며 오르내려 진짜 재생 중처럼 보인다.

<main class="stage is-demo">
<div class="pcast">
<button class="pcast__play" type="button" aria-label="재생">
<svg class="ic-play" viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor"><path d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" opacity=".2"/><path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z"/></g></svg>
<svg class="ic-pause" viewBox="0 0 256 256" width="18" height="18" aria-hidden="true" hidden><g fill="currentColor"><path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2"/><path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z"/></g></svg>
</button>
<div class="pcast__body">
<p class="pcast__title">EP.09 CSS 만으로 배지 만들기</p>
<p class="pcast__meta">Godrich Story FM · 24:10</p>
<div class="pcast__wave" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 300ms — 막대 하나가 오르내리는 한 주기(막대마다 다른 배수로 어긋난다)
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-ink);
.pcast {
display: flex;
align-items: center;
gap: $sp-3;
width: 280px;
padding: $sp-3;
border-radius: $r-card;
background: #201d29;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.pcast__play {
flex: 0 0 auto;
width: 44px; height: 44px;
border-radius: 50%;
border: 0;
display: grid; place-items: center;
background: $color;
color: $stage-ink;
cursor: pointer;
}
.pcast__play svg { transform: translateX(1px); }
.pcast__play:hover { filter: brightness(1.08); }
.pcast__body { flex: 1 1 auto; min-width: 0; }
.pcast__title { margin: 0; font-size: 12px; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pcast__meta { margin: $sp-1 0 $sp-2; font-size: 11px; color: #9491a3; }
.pcast__wave { display: flex; align-items: flex-end; gap: $sp-1; height: 20px; }
.pcast__wave span {
display: block;
width: 3px;
height: 6px;
border-radius: $r-xs;
background: $color;
transform-origin: bottom; // 규칙8: 파형 막대 아래에서 오름
}
@for $i from 1 through 8 {
.pcast__wave span:nth-child(#{$i}) { animation-delay: $i * 80ms; }
}
// 자동 재생 루프 — 파형이 재생 중인 것처럼 오르내린다
.stage.is-demo .pcast__wave span { animation: pcast-bar #{$duration * 3} $easing infinite alternate; }
.pcast__wave.is-paused span { animation-play-state: paused; height: 6px; }
@keyframes pcast-bar {
from { transform: scaleY(.3); }
to { transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .pcast__wave span { animation: none; transform: scaleY(.6); }
}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);
}
.pcast {
display: flex;
align-items: center;
gap: 12px;
width: 280px;
padding: 12px;
border-radius: 16px;
background: #201d29;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.pcast__play {
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 50%;
border: 0;
display: grid;
place-items: center;
background: #4cd4a6;
color: #17141a;
cursor: pointer;
}
.pcast__play svg {
transform: translateX(1px);
}
.pcast__play:hover {
filter: brightness(1.08);
}
.pcast__body {
flex: 1 1 auto;
min-width: 0;
}
.pcast__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pcast__meta {
margin: 4px 0 8px;
font-size: 11px;
color: #9491a3;
}
.pcast__wave {
display: flex;
align-items: flex-end;
gap: 4px;
height: 20px;
}
.pcast__wave span {
display: block;
width: 3px;
height: 6px;
border-radius: 4px;
background: #4cd4a6;
transform-origin: bottom;
}
.pcast__wave span:nth-child(1) {
animation-delay: 80ms;
}
.pcast__wave span:nth-child(2) {
animation-delay: 160ms;
}
.pcast__wave span:nth-child(3) {
animation-delay: 240ms;
}
.pcast__wave span:nth-child(4) {
animation-delay: 320ms;
}
.pcast__wave span:nth-child(5) {
animation-delay: 400ms;
}
.pcast__wave span:nth-child(6) {
animation-delay: 480ms;
}
.pcast__wave span:nth-child(7) {
animation-delay: 560ms;
}
.pcast__wave span:nth-child(8) {
animation-delay: 640ms;
}
.stage.is-demo .pcast__wave span {
animation: pcast-bar 900ms ease-out infinite alternate;
}
.pcast__wave.is-paused span {
animation-play-state: paused;
height: 6px;
}
@keyframes pcast-bar {
from {
transform: scaleY(0.3);
}
to {
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .pcast__wave span {
animation: none;
transform: scaleY(0.6);
}
}// 실제 동작: 재생 버튼을 누르면 아이콘이 바뀌고 파형 애니메이션이 실제로 멈췄다 다시 움직인다.
var btn = document.querySelector('.pcast__play');
var wave = document.querySelector('.pcast__wave');
var play = document.querySelector('.ic-play');
var pause = document.querySelector('.ic-pause');
var playing = false;
btn.addEventListener('click', function () {
playing = !playing;
play.hidden = playing;
pause.hidden = !playing;
wave.classList.toggle('is-paused', !playing);
btn.setAttribute('aria-label', playing ? '일시정지' : '재생');
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PodcastPlayer.module.scss";
export type PodcastPlayerProps = {
duration?: number;
easing?: string;
color?: string;
title?: string;
};
export default function PodcastPlayer({ duration = 300, easing = "ease-out", color = "#4cd4a6", title = "EP.09 CSS 만으로 배지 만들기" }: PodcastPlayerProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [playing, setPlaying] = useState(false);
return (
<div className={styles.player} style={vars}>
<button className={styles.play} type="button" aria-label={playing ? "일시정지" : "재생"} onClick={() => setPlaying((v) => !v)}>
{playing ? (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" />
<path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z" />
</g></svg>
) : (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" />
<path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z" />
</g></svg>
)}
</button>
<div className={styles.body}>
<p className={styles.title}>{title}</p>
<p className={styles.meta}>Godrich Story FM · 24:10</p>
<div className={`${styles.wave} ${!playing ? styles.paused : ""}`} aria-hidden="true">
{Array.from({ length: 8 }).map((_, i) => (
<span key={i} style={{ animationDelay: `${i * 80}ms` }} />
))}
</div>
</div>
</div>
);
}06 · 팟캐스트 플레이어 임베드 위젯 — 측정 누적면적 0.083% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
블로그 글 안 팟캐스트 회차 카드, 사이드바의 최신 에피소드 위젯에 쓴다.
.wave span { animation: bar #{$duration * 3} ease-out infinite alternate; }
.wave.is-paused span { animation-play-state: paused; }
@keyframes bar {
from { transform: scaleY(.3); }
to { transform: scaleY(1); }
}
재생 버튼을 누르면 파형이 실제로 멈췄다가 다시 움직인다 — animation-play-state 하나로 재생 상태와 애니메이션을 묶었다.
07깃허브 저장소 통계 카드
무엇이 보이나
별 아이콘이 눌러보라는 듯 좌우로 짧게 흔들린다.

<main class="stage is-demo">
<div class="ghcard">
<div class="ghcard__head">
<span class="ghcard__repo">godrichstory / demo-widgets</span>
</div>
<p class="ghcard__desc">임베드 위젯 9종 — vanilla + React, MIT</p>
<div class="ghcard__stats">
<span class="ghcard__lang"><i class="ghcard__dot" aria-hidden="true"></i>SCSS</span>
<button class="ghcard__star" type="button" aria-pressed="false">
<svg class="ic-star" viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.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.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z"/></g></svg>
<span class="ghcard__star-count">482</span>
</button>
<span class="ghcard__fork">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M88 64a24 24 0 1 1-24-24a24 24 0 0 1 24 24m104-24a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M224 64a32 32 0 1 0-40 31v17a8 8 0 0 1-8 8H80a8 8 0 0 1-8-8V95a32 32 0 1 0-16 0v17a24 24 0 0 0 24 24h40v25a32 32 0 1 0 16 0v-25h40a24 24 0 0 0 24-24V95a32.06 32.06 0 0 0 24-31M48 64a16 16 0 1 1 16 16a16 16 0 0 1-16-16m96 128a16 16 0 1 1-16-16a16 16 0 0 1 16 16m48-112a16 16 0 1 1 16-16a16 16 0 0 1-16 16"/></g></svg>
56
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 별 아이콘 안내 펄스
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.ghcard {
width: 264px;
padding: $sp-4;
border-radius: $r-card;
border: 1px solid #e7e5ea;
background: #fff;
box-shadow: $shadow-press;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ghcard__repo { font-size: 13px; font-weight: 700; color: $color; }
.ghcard__desc { margin: $sp-2 0 $sp-3; font-size: 12px; color: #6b6875; line-height: 1.5; }
.ghcard__stats { display: flex; align-items: center; gap: $sp-4; font-size: 12px; color: #6b6875; }
.ghcard__lang { display: flex; align-items: center; gap: $sp-1; }
.ghcard__dot { width: 10px; height: 10px; border-radius: 50%; background: #caabd6; display: inline-block; }
.ghcard__star, .ghcard__fork { display: flex; align-items: center; gap: $sp-1; }
.ghcard__star { border: 0; background: none; padding: 0; cursor: pointer; color: #6b6875; }
.ghcard__star:hover, .ghcard__star.is-active { color: #baa272; }
.ghcard__star.is-active .ic-star { fill: #baa272; }
// 자동 재생 루프 — 별 아이콘이 눌러보라는 듯 전 구간 계속 흔들린다 (절반 넘게 멈춰 있으면 프레임 판정에서 떨어진다)
.stage.is-demo .ghcard__star .ic-star { animation: ghcard-wiggle $duration $easing infinite; transform-origin: 50% 90%; }
@keyframes ghcard-wiggle {
0% { transform: rotate(0) scale(1); }
20% { transform: rotate(-16deg) scale(1.15); }
40% { transform: rotate(12deg) scale(1.05); }
60% { transform: rotate(-10deg) scale(1.15); }
80% { transform: rotate(8deg) scale(1.05); }
100% { transform: rotate(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ghcard__star .ic-star { animation: none; }
}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);
}
.ghcard {
width: 264px;
padding: 16px;
border-radius: 16px;
border: 1px solid #e7e5ea;
background: #fff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ghcard__repo {
font-size: 13px;
font-weight: 700;
color: #2f6df6;
}
.ghcard__desc {
margin: 8px 0 12px;
font-size: 12px;
color: #6b6875;
line-height: 1.5;
}
.ghcard__stats {
display: flex;
align-items: center;
gap: 16px;
font-size: 12px;
color: #6b6875;
}
.ghcard__lang {
display: flex;
align-items: center;
gap: 4px;
}
.ghcard__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #caabd6;
display: inline-block;
}
.ghcard__star, .ghcard__fork {
display: flex;
align-items: center;
gap: 4px;
}
.ghcard__star {
border: 0;
background: none;
padding: 0;
cursor: pointer;
color: #6b6875;
}
.ghcard__star:hover, .ghcard__star.is-active {
color: #baa272;
}
.ghcard__star.is-active .ic-star {
fill: #baa272;
}
.stage.is-demo .ghcard__star .ic-star {
animation: ghcard-wiggle 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
transform-origin: 50% 90%;
}
@keyframes ghcard-wiggle {
0% {
transform: rotate(0) scale(1);
}
20% {
transform: rotate(-16deg) scale(1.15);
}
40% {
transform: rotate(12deg) scale(1.05);
}
60% {
transform: rotate(-10deg) scale(1.15);
}
80% {
transform: rotate(8deg) scale(1.05);
}
100% {
transform: rotate(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ghcard__star .ic-star {
animation: none;
}
}// 실제 동작: Star 버튼을 누르면 카운트가 오르내리고 별이 채워진 상태로 바뀐다.
var btn = document.querySelector('.ghcard__star');
var count = document.querySelector('.ghcard__star-count');
btn.addEventListener('click', function () {
var pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
btn.classList.toggle('is-active', !pressed);
count.textContent = parseInt(count.textContent, 10) + (pressed ? -1 : 1);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GithubRepoCard.module.scss";
export type GithubRepoCardProps = {
duration?: number;
easing?: string;
color?: string;
repo?: string;
};
export default function GithubRepoCard({ duration = 2000, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6", repo = "godrichstory / demo-widgets" }: GithubRepoCardProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [starred, setStarred] = useState(false);
const [stars, setStars] = useState(482);
return (
<div className={styles.card} style={vars}>
<div><span className={styles.repo}>{repo}</span></div>
<p className={styles.desc}>임베드 위젯 9종 — vanilla + React, MIT</p>
<div className={styles.stats}>
<span className={styles.lang}><i className={styles.dot} aria-hidden="true" />SCSS</span>
<button
className={`${styles.star} ${starred ? styles.active : ""}`}
type="button" aria-pressed={starred}
onClick={() => { setStarred((v) => !v); setStars((n) => n + (starred ? -1 : 1)); }}
>
<svg className={styles.icStar} viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" />
<path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.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.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z" />
</g></svg>
<span>{stars}</span>
</button>
<span className={styles.fork}>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M88 64a24 24 0 1 1-24-24a24 24 0 0 1 24 24m104-24a24 24 0 1 0 24 24a24 24 0 0 0-24-24" />
<path d="M224 64a32 32 0 1 0-40 31v17a8 8 0 0 1-8 8H80a8 8 0 0 1-8-8V95a32 32 0 1 0-16 0v17a24 24 0 0 0 24 24h40v25a32 32 0 1 0 16 0v-25h40a24 24 0 0 0 24-24V95a32.06 32.06 0 0 0 24-31M48 64a16 16 0 1 1 16 16a16 16 0 0 1-16-16m96 128a16 16 0 1 1-16-16a16 16 0 0 1 16 16m48-112a16 16 0 1 1 16-16a16 16 0 0 1-16 16" />
</g></svg>
56
</span>
</div>
</div>
);
}07 · 깃허브 저장소 통계 카드 — 측정 누적면적 0.198% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
실제 GitHub API 를 호출하지 않는 순수 CSS 카드로, 오픈소스 프로젝트 소개 글의 저장소 카드나 포트폴리오의 코드 링크에 쓴다.
.icStar { animation: wiggle $duration $easing infinite; transform-origin: 50% 90%; }
@keyframes wiggle {
0% { transform: rotate(0) scale(1); }
20% { transform: rotate(-16deg) scale(1.15); }
40% { transform: rotate(12deg) scale(1.05); }
60% { transform: rotate(-10deg) scale(1.15); }
100% { transform: rotate(0) scale(1); }
}
Star 버튼을 누르면 카운트가 실제로 오르내리고 별이 채워진 색으로 바뀐다.
08뉴스레터 구독 임베드 위젯
화면에서 일어나는 일
편지 아이콘이 떠올랐다 가라앉기를 반복한다.

<main class="stage is-demo">
<form class="ncard" novalidate>
<span class="ncard__icon" aria-hidden="true">
<svg class="ic-mail" viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="m224 56l-96 88l-96-88Z" opacity=".2"/><path d="M224 48H32a8 8 0 0 0-8 8v136a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a8 8 0 0 0-8-8m-20.57 16L128 133.15L52.57 64ZM216 192H40V74.19l82.59 75.71a8 8 0 0 0 10.82 0L216 74.19z"/></g></svg>
<svg class="ic-sent" viewBox="0 0 256 256" width="22" height="22" hidden><g fill="currentColor"><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2"/><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66"/></g></svg>
</span>
<p class="ncard__title">새 데모가 나올 때마다 메일로</p>
<div class="ncard__row">
<label class="ncard__label" for="ncard-email-234-08">이메일</label>
<input class="ncard__input" id="ncard-email-234-08" type="email" placeholder="[email protected]" required>
<button class="ncard__btn" type="submit">구독</button>
</div>
<p class="ncard__msg" role="status"></p>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 편지 아이콘이 떠올랐다 가라앉는 한 주기
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-orange);
.ncard {
width: 272px;
box-sizing: border-box;
padding: $sp-4;
border-radius: $r-card;
text-align: center;
background: linear-gradient(160deg, #fff, $subject-cream 120%);
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ncard__icon {
display: grid; place-items: center;
width: 44px; height: 44px;
margin: 0 auto $sp-2;
border-radius: 50%;
background: rgba($color, .16);
color: $color;
}
.ncard__title { margin: 0 0 $sp-3; font-size: 13px; font-weight: 700; color: #3a2f1f; }
.ncard__row { display: flex; flex-wrap: wrap; gap: $sp-2; justify-content: center; }
.ncard__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.ncard__input {
flex: 1 1 140px;
min-width: 0;
border: 1px solid #e7ddc8;
border-radius: $r-pill;
padding: $sp-2 $sp-3;
font-size: 12px;
font-family: inherit;
}
.ncard__input:focus { outline: 2px solid $color; outline-offset: 2px; }
.ncard__input:disabled { background: #f4efe4; color: #9c8f74; }
.ncard__btn {
flex: 0 0 auto;
border: 0;
border-radius: $r-pill;
background: $color;
color: $subject-ink; // 파스텔 면 위 흰 글자는 2.20:1 — 잉크로 8.28:1
padding: $sp-2 $sp-4;
font-size: 12px; font-weight: 700;
cursor: pointer;
}
.ncard__btn:hover { filter: brightness(1.06); }
.ncard__btn:disabled { cursor: default; filter: none; opacity: .8; }
.ncard__msg { min-height: 16px; margin: $sp-2 0 0; font-size: 11px; }
.ncard__msg.is-error { color: $error; }
.ncard__msg.is-ok { color: #50a26e; }
// 자동 재생 루프 — 편지가 살짝 떠올랐다 가라앉는다
.stage.is-demo .ncard__icon { animation: ncard-float $duration $easing infinite; }
@keyframes ncard-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ncard__icon { animation: none; }
}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);
}
.ncard {
width: 272px;
box-sizing: border-box;
padding: 16px;
border-radius: 16px;
text-align: center;
background: linear-gradient(160deg, #fff, #fff7e6 120%);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ncard__icon {
display: grid;
place-items: center;
width: 44px;
height: 44px;
margin: 0 auto 8px;
border-radius: 50%;
background: rgba(185, 160, 255, 0.16);
color: #b9a0ff;
}
.ncard__title {
margin: 0 0 12px;
font-size: 13px;
font-weight: 700;
color: #3a2f1f;
}
.ncard__row {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.ncard__label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.ncard__input {
flex: 1 1 140px;
min-width: 0;
border: 1px solid #e7ddc8;
border-radius: 999px;
padding: 8px 12px;
font-size: 12px;
font-family: inherit;
}
.ncard__input:focus {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.ncard__input:disabled {
background: #f4efe4;
color: #9c8f74;
}
.ncard__btn {
flex: 0 0 auto;
border: 0;
border-radius: 999px;
background: #b9a0ff;
color: #17141a;
padding: 8px 16px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.ncard__btn:hover {
filter: brightness(1.06);
}
.ncard__btn:disabled {
cursor: default;
filter: none;
opacity: 0.8;
}
.ncard__msg {
min-height: 16px;
margin: 8px 0 0;
font-size: 11px;
}
.ncard__msg.is-error {
color: #c2361a;
}
.ncard__msg.is-ok {
color: #50a26e;
}
.stage.is-demo .ncard__icon {
animation: ncard-float 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ncard-float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ncard__icon {
animation: none;
}
}// 실제 동작: 이메일 형식을 검사해 통과하면 완료 상태로 바뀐다 (실제 전송은 없는 데모).
var form = document.querySelector('.ncard');
var input = document.querySelector('.ncard__input');
var btn = document.querySelector('.ncard__btn');
var msg = document.querySelector('.ncard__msg');
var mail = document.querySelector('.ic-mail');
var sent = document.querySelector('.ic-sent');
form.addEventListener('submit', function (e) {
e.preventDefault();
var ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.value);
if (!ok) { msg.textContent = '이메일 형식을 확인해 주세요'; msg.className = 'ncard__msg is-error'; return; }
input.disabled = true;
btn.disabled = true;
btn.textContent = '완료';
mail.hidden = true;
sent.hidden = false;
msg.textContent = '구독 완료 — 확인 메일을 보냈어요';
msg.className = 'ncard__msg is-ok';
});import { useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./NewsletterSignup.module.scss";
export type NewsletterSignupProps = {
duration?: number;
easing?: string;
color?: string;
};
export default function NewsletterSignup({ duration = 2000, easing = "cubic-bezier(.2,.8,.2,1)", color = "#b9a0ff" }: NewsletterSignupProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "error" | "ok">("idle");
function onSubmit(e: { preventDefault: () => void }) {
e.preventDefault();
const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
setStatus(ok ? "ok" : "error");
}
return (
<form className={styles.card} style={vars} onSubmit={onSubmit} noValidate>
<span className={styles.icon} aria-hidden="true">
{status === "ok" ? (
<svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor">
<path opacity=".2" d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" />
<path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66" />
</g></svg>
) : (
<svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor">
<path opacity=".2" d="m224 56l-96 88l-96-88Z" />
<path d="M224 48H32a8 8 0 0 0-8 8v136a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a8 8 0 0 0-8-8m-20.57 16L128 133.15L52.57 64ZM216 192H40V74.19l82.59 75.71a8 8 0 0 0 10.82 0L216 74.19z" />
</g></svg>
)}
</span>
<p className={styles.title}>새 데모가 나올 때마다 메일로</p>
<div className={styles.row}>
<label className={styles.srOnly} htmlFor="ncard-email-react">이메일</label>
<input
className={styles.input} id="ncard-email-react" type="email" placeholder="[email protected]"
value={email} disabled={status === "ok"} onChange={(e: ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)} required
/>
<button className={styles.btn} type="submit" disabled={status === "ok"}>{status === "ok" ? "완료" : "구독"}</button>
</div>
<p className={`${styles.msg} ${status === "error" ? styles.msgError : status === "ok" ? styles.msgOk : ""}`} role="status">
{status === "error" ? "이메일 형식을 확인해 주세요" : status === "ok" ? "구독 완료 — 확인 메일을 보냈어요" : ""}
</p>
</form>
);
}08 · 뉴스레터 구독 임베드 위젯 — 측정 누적면적 0.66% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
글 하단 구독 유도 카드, 사이드바의 뉴스레터 가입 폼에 쓴다.
.icon { animation: float $duration $easing infinite; }
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@media (prefers-reduced-motion: reduce) {
.icon { animation: none; }
}
실제로 이메일 형식을 정규식으로 검사해 통과하면 아이콘이 종이비행기 모양으로 바뀌며 완료 문구가 뜬다.
09라이브 채팅 버블 런처
어떤 움직임인가
런처 둘레 흰 링이 레이더처럼 퍼지고 배지 숫자가 숨쉬듯 커졌다 작아진다.

<main class="stage is-demo">
<div class="lchat">
<div class="lchat__panel" hidden>
<header class="lchat__head">
<span>실시간 문의</span>
<button class="lchat__close" type="button" aria-label="닫기">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>
</button>
</header>
<p class="lchat__msg">안녕하세요! 무엇을 도와드릴까요?</p>
<div class="lchat__input-row">
<input class="lchat__input" type="text" placeholder="메시지를 입력하세요" aria-label="메시지">
<button class="lchat__send" type="button">전송</button>
</div>
</div>
<button class="lchat__launcher" type="button" aria-label="채팅 열기" aria-expanded="false">
<span class="lchat__ring" aria-hidden="true"></span>
<svg viewBox="0 0 256 256" width="24" height="24" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-4-1.08a7.9 7.9 0 0 0-2.53.42L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216m12-88a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-44 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12m88 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg>
<span class="lchat__badge">2</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 런처 둘레 링이 신호를 보내는 한 주기
$easing: $ease-out;
$color: $subject-lilac;
@include stage($stage-orange);
.lchat {
position: relative;
width: 260px;
height: 150px;
}
.lchat__launcher {
position: absolute;
right: 0; bottom: 0;
width: 60px; height: 60px;
border-radius: 50%;
border: 0;
display: grid; place-items: center;
background: linear-gradient(150deg, $subject-pink, $color);
color: $subject-ink; // 파스텔 면 위 흰 글자는 2.20:1 — 잉크로 9.43:1
cursor: pointer;
box-shadow: $shadow-raised;
}
.lchat__ring {
position: absolute;
inset: -8px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, .6);
opacity: 0;
transform: scale(.85);
}
.lchat__badge {
position: absolute;
top: -4px; right: -4px;
min-width: 20px; height: 20px;
border-radius: $r-pill;
background: $subject-ink; // 흰 배경 + 라일락 글자는 2.20:1 — 먹 배경 + 크림 글자로
color: $subject-cream;
display: grid; place-items: center;
font-size: 11px; font-weight: 800;
box-shadow: 0 0 0 2px $stage-orange;
}
.lchat__panel {
position: absolute;
right: 0; bottom: 72px;
width: 224px;
padding: $sp-3;
border-radius: $r-card;
background: #fff;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lchat__head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 700; color: $stage-ink; }
.lchat__close { border: 0; background: none; padding: 0; color: #6b6875; cursor: pointer; }
.lchat__msg { margin: $sp-2 0; padding: $sp-2; border-radius: $r-sm; background: #f4f0fa; font-size: 12px; color: #3a2f1f; }
.lchat__input-row { display: flex; gap: $sp-1; }
.lchat__input { flex: 1 1 auto; min-width: 0; border: 1px solid #e7e5ea; border-radius: $r-sm; padding: $sp-1 $sp-2; font-size: 12px; font-family: inherit; }
.lchat__send { flex: 0 0 auto; border: 0; border-radius: $r-sm; background: $color; color: #fff; padding: $sp-1 $sp-2; font-size: 12px; cursor: pointer; }
// 자동 재생 루프 — 링이 레이더처럼 퍼지고 배지가 숨쉰다
.stage.is-demo .lchat__ring { animation: lchat-ping $duration $easing infinite; }
.stage.is-demo .lchat__badge { animation: lchat-bounce $duration $ease-pop infinite; }
@keyframes lchat-ping {
0% { opacity: .5; transform: scale(.85); }
70% { opacity: 0; transform: scale(1.25); }
100% { opacity: 0; transform: scale(1.25); }
}
@keyframes lchat-bounce {
0%, 40%, 100% { transform: scale(1); }
20% { transform: scale(1.25); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .lchat__ring { animation: none; opacity: 0; }
.stage.is-demo .lchat__badge { animation: none; }
}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);
}
.lchat {
position: relative;
width: 260px;
height: 150px;
}
.lchat__launcher {
position: absolute;
right: 0;
bottom: 0;
width: 60px;
height: 60px;
border-radius: 50%;
border: 0;
display: grid;
place-items: center;
background: linear-gradient(150deg, #ff9ebb, #b9a0ff);
color: #17141a;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.lchat__ring {
position: absolute;
inset: -8px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.6);
opacity: 0;
transform: scale(0.85);
}
.lchat__badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 20px;
height: 20px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
display: grid;
place-items: center;
font-size: 11px;
font-weight: 800;
box-shadow: 0 0 0 2px #ff4d1f;
}
.lchat__panel {
position: absolute;
right: 0;
bottom: 72px;
width: 224px;
padding: 12px;
border-radius: 16px;
background: #fff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lchat__head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 700;
color: #17141a;
}
.lchat__close {
border: 0;
background: none;
padding: 0;
color: #6b6875;
cursor: pointer;
}
.lchat__msg {
margin: 8px 0;
padding: 8px;
border-radius: 8px;
background: #f4f0fa;
font-size: 12px;
color: #3a2f1f;
}
.lchat__input-row {
display: flex;
gap: 4px;
}
.lchat__input {
flex: 1 1 auto;
min-width: 0;
border: 1px solid #e7e5ea;
border-radius: 8px;
padding: 4px 8px;
font-size: 12px;
font-family: inherit;
}
.lchat__send {
flex: 0 0 auto;
border: 0;
border-radius: 8px;
background: #b9a0ff;
color: #fff;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
}
.stage.is-demo .lchat__ring {
animation: lchat-ping 2s ease-out infinite;
}
.stage.is-demo .lchat__badge {
animation: lchat-bounce 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes lchat-ping {
0% {
opacity: 0.5;
transform: scale(0.85);
}
70% {
opacity: 0;
transform: scale(1.25);
}
100% {
opacity: 0;
transform: scale(1.25);
}
}
@keyframes lchat-bounce {
0%, 40%, 100% {
transform: scale(1);
}
20% {
transform: scale(1.25);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .lchat__ring {
animation: none;
opacity: 0;
}
.stage.is-demo .lchat__badge {
animation: none;
}
}// 실제 동작: 런처를 누르면 채팅 패널이 실제로 열리고 닫히며, 열릴 때 입력창에 포커스가 간다(스크롤은 강제하지 않는다).
var launcher = document.querySelector('.lchat__launcher');
var panel = document.querySelector('.lchat__panel');
var closeBtn = document.querySelector('.lchat__close');
var input = document.querySelector('.lchat__input');
var badge = document.querySelector('.lchat__badge');
function open() {
panel.hidden = false;
launcher.setAttribute('aria-expanded', 'true');
badge.hidden = true;
input.focus({ preventScroll: true });
}
function close() {
panel.hidden = true;
launcher.setAttribute('aria-expanded', 'false');
}
launcher.addEventListener('click', function () {
if (panel.hidden) open(); else close();
});
closeBtn.addEventListener('click', close);import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LiveChatBubble.module.scss";
export type LiveChatBubbleProps = {
duration?: number;
easing?: string;
color?: string;
unread?: number;
};
export default function LiveChatBubble({ duration = 2000, easing = "ease-out", color = "#b9a0ff", unread = 2 }: LiveChatBubbleProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [open, setOpen] = useState(false);
const [seen, setSeen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
function toggle() {
const next = !open;
setOpen(next);
if (next) {
setSeen(true);
requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
}
}
return (
<div className={styles.wrap} style={vars}>
{open && (
<div className={styles.panel}>
<header className={styles.head}>
<span>실시간 문의</span>
<button className={styles.close} type="button" aria-label="닫기" onClick={() => setOpen(false)}>
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" />
<path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g></svg>
</button>
</header>
<p className={styles.msg}>안녕하세요! 무엇을 도와드릴까요?</p>
<div className={styles.inputRow}>
<input ref={inputRef} className={styles.input} type="text" placeholder="메시지를 입력하세요" aria-label="메시지" />
<button className={styles.send} type="button">전송</button>
</div>
</div>
)}
<button className={styles.launcher} type="button" aria-label="채팅 열기" aria-expanded={open} onClick={toggle}>
<span className={styles.ring} aria-hidden="true" />
<svg viewBox="0 0 256 256" width="24" height="24" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" />
<path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-4-1.08a7.9 7.9 0 0 0-2.53.42L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216m12-88a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-44 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12m88 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12" />
</g></svg>
{!seen && <span className={styles.badge}>{unread}</span>}
</button>
</div>
);
}09 · 라이브 채팅 버블 런처 — 측정 누적면적 2.226% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
사이트 우하단 고정 문의 버튼, 결제 페이지의 실시간 상담 진입점에 쓴다.
.ring { animation: ping $duration $easing infinite; }
.badge { animation: bounce $duration $ease-pop infinite; }
@keyframes ping {
0% { opacity: .5; transform: scale(.85); }
70%, 100% { opacity: 0; transform: scale(1.25); }
}
실제로 누르면 채팅 패널이 열리고, .focus({ preventScroll: true }) 덕분에 입력창에 포커스가 가도 부모 페이지가 강제로 스크롤되지 않는다.
어디서 깨지는가 — 320px 에서 카드가 화면을 넘긴다
X 포스트 카드(03)와 후기 카드(04)를 처음 만들었을 때는 카드 폭을 280px 로 두고 문단·여백 값도 넉넉하게 잡았다. 블로그설정.json 의 폰 데모 폭 320px 로 Playwright 렌더를 돌려보니 document.body.scrollHeight 가 200px 예산을 14px·2px 씩 넘겼다 — document.documentElement 만 재면 이 초과분을 놓친다(스테이지 CSS 가 overflow: hidden 을 걸어 스크롤바 자체가 안 생기기 때문). 카드 폭을 256px 로 줄이고 문단·버튼 사이 여백을 토큰 한 단계씩($sp-4→$sp-3, $sp-3→$sp-2) 낮추자 정확히 200px 에 맞았다. 지도 카드(05)는 지도 영역 높이를 120px 에서 88px 로 줄인 게 더 컸다. 반경·간격 규칙은 border-radius 에 $r-* 토큰만, gap·padding·margin 의 px 값에는 4px 배수만 쓰게 강제돼 있어 재생 아이콘을 1~2px 옮기려던 margin-left 도 전부 transform: translateX() 로 바꿔야 했다. 임베드 카드 안에 숫자를 그려 넣어야 할 때는 SVG 그래프 만들기의 좌표 계산을 그대로 가져다 쓰면 됩니다. 아홉 개 zip 을 열 때 요구하는 암호는 xc2tmqdq 인데, 소문자·숫자만 섞인 짧은 문자열이라 대문자 없이 그대로 옮겨 붙이면 된다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce 에서 반복 애니메이션이 꺼지고 정지된 모양만 남는다 — 02번 재생 링과 09번 채팅 버블 링은 opacity: 0 으로 고정해 링이 멈춘 채로 떠 있지 않다. 버튼마다 aria-label·aria-pressed·aria-expanded 를 붙여 스크린리더가 지금 상태(좋아요를 눌렀는지, 패널이 열렸는지)를 읽을 수 있고, 채팅 버블(09)의 입력창 포커스는 { preventScroll: true } 로 걸어 화면이 갑자기 움직이지 않는다. 운영체제에서 이 값을 어디서 켜는지는 MDN 의 prefers-reduced-motion 문서 를 참고한다.
FAQ
뉴스레터 구독 카드나 이 카드들로 진짜 유튜브·X·지도가 열리나요?
아니다. 9개 전부 실제 API·iframe 을 부르지 않는 CSS·바닐라 JS 목업이다. 02번 영상 카드는 재생 버튼을 누르면 진행바만 채워지고, 03번 X 카드는 좋아요·리포스트 숫자가 이 카드 안에서만 오르내린다. 진짜 임베드가 필요하면 각 서비스의 공식 embed 코드를 그 자리에 넣고, 이 카드의 CSS 는 로딩 전 자리표시자로 쓰면 된다. 더 많은 임베드 관련 패턴은 사이트 검색 에서 "임베드"로 찾아볼 수 있다. 위젯이 결국 자리를 잡는 페이지 아래쪽 구성은 웹사이트 푸터 디자인 패턴에서 통째로 다룹니다.
색과 문구를 우리 브랜드에 맞게 바꿀 수 있나요?
된다. 대부분의 데모는 SCSS 맨 위 $color 변수만 바꾸면 애니메이션 색이 따라가고, $duration·$easing 은 속도와 리듬을 조절한다. 텍스트(회사명·주소·후기 문구)는 index.html 안 문자열을 직접 고치면 되고, React 버전은 각 컴포넌트의 props(title·address·quote 등)로 같은 걸 한다.
320px 초과 여부는 어떻게 직접 확인하나요?
브라우저 개발자 도구에서 뷰포트를 320px 로 좁힌 뒤 콘솔에 document.documentElement.scrollWidth·document.body.scrollWidth 를 각각 찍어 window.innerWidth 와 비교하면 된다. documentElement 만 재면 이 글의 함정 섹션처럼 overflow: hidden 뒤에 숨은 초과분을 놓친다. 이 사이트가 데모를 어떻게 만들고 검증하는지는 소개 페이지 에 정리돼 있다.









