9 Social Share Button Design Embed Widgets
Social share button design is a small standalone card for one job — share, reviews, contact.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Social share button row
- 02 Video embed play-button overlay
- 03 X post embed card mock
- 04 Testimonial review embed widget
- 05 Map location embed card
- 06 Podcast player embed widget
- 07 GitHub repo stats card embed
- 08 Newsletter signup embed widget
- 09 Live chat bubble embed launcher
The order isn't popularity — it's the sequence a visitor actually meets while reading a post. A share row at the bottom (01) comes first, then an eye-catching video card (02), then trust-builders like an X post (03) and a testimonial (04), then a location card (05) that turns a reader into a visitor. Podcast (06) and GitHub (07) give more to explore, a newsletter card (08) tries to keep them before they leave, and a chat bubble button (09) stays on screen the whole time. They are all pieces that ride on someone else's page, but building the playback controls yourself is Custom Video Player CSS Controls, and checking that no widget string goes missing per language is i18n JSON Checks.
01Social share button row
What you see in this share button ui design
Four pastel circular buttons — pink, mint, lilac, sky — pop up one after another with a quick scale bounce.

<main class="stage is-demo">
<div class="sbar" role="group" aria-label="Share">
<button class="sbar__btn sbar__btn--pink" type="button" data-action="copy" aria-label="Copy link">
<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="Like">
<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="Share via comment">
<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="Share">
<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('Link copied'); }).catch(function () { say('Copy failed — select it manually'); });
} else {
say('Link copied');
}
} 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 ? 'Liked' : 'Like removed');
} else if (action === 'chat') {
say('Jumping to the comments');
} else if (action === 'share') {
say('Opening the share sheet');
}
});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 · Social share button row — Measured area 4.878% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Use it in a share row at the bottom of a post or the social-share spot on a product page.
.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); }
}
The link button really calls navigator.clipboard.writeText(), and the like button toggles aria-pressed to keep the pressed state.
02Video embed play-button overlay
What happens on screen
A white ring around the play button on a dark thumbnail expands and fades like a radar ping.

<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="Play video">
<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;
}
}// 실제 동작: Play 버튼을 누르면 아이콘이 바뀌고 진행바가 실제로 채워진다(모형 Play — 진짜 영상 파일은 없음).
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 ? 'Pause' : 'Play video');
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 · Video embed play-button overlay — Measured area 2.308% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
It never loads a real YouTube iframe — it's a CSS placeholder card for the moment before the real player loads.
.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); }
}
Clicking swaps the icon between play and pause, and the progress bar really fills through requestAnimationFrame — there is no actual video file behind it.
03X post embed card mock
What moves
The heart icon breathes — scale and opacity rising and falling — to hint that it's clickable.

<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="Verified"><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">An embed card builds that "this looks like a real post" trust just by copying this layout in CSS — no real Twitter/X API call needed.</p>
<div class="xpost__actions">
<button class="xpost__act xpost__act--chat" type="button" data-action="reply" aria-label="Reply">
<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="Repost">
<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="Like">
<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="Share">
<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;
}
}// 실제 동작: Like·리포스트를 누르면 카운트가 실제로 오르내리고 색이 바뀐다.
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 post embed card mock — Measured area 0.315% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
It never calls the real X API, so it stands in for a screenshot of a social reaction on a testimonial page.
.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; }
Clicking like or repost really bumps the count and changes color — every number on screen only lives inside this one card.
04Testimonial review embed widget
What you see
Five stars pop in one by one, left to right, with a bounce.

<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="Rated 5 out of 5 stars">
<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">"One password-protected zip and the demo just dropped right in. I didn't need to know any code."</p>
<div class="tcard__who">
<span class="tcard__avatar" aria-hidden="true">M</span>
<span class="tcard__name">Minji · solo shop owner</span>
</div>
<button class="tcard__helpful" type="button" aria-pressed="false">
<span class="tcard__helpful-label">Helpful</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 · Testimonial review embed widget — Measured area 0.689% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Use it for card-style reviews on a testimonials page or the customer-review section of a landing page.
.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; }
}
The helpful button carries aria-pressed for its pressed state, and clicking it actually increments the count.
05Map location embed card
What happens on screen
The map pin drops in from above with a small bounce before settling.

<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 Eoulmadang-ro, Mapo-gu, Seoul</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>
Directions
</button>
<p class="mcard__info" hidden>2.4km · 32 min walk · 12 min by bus</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;
}
}// 실제 동작: Directions 버튼을 누르면 거리·소요시간 패널이 실제로 열리고 닫힌다.
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 · Map location embed card — Measured area 0.37% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Use it as a location card on a store or office intro page, or the directions section of a contact page.
.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%); }
}
Clicking the directions button really opens a panel with distance and time via aria-expanded — there's no real map tile, just two CSS roads.
06Podcast player embed widget
What moves
Eight waveform bars rise and fall at staggered intervals so it looks like real playback.

<main class="stage is-demo">
<div class="pcast">
<button class="pcast__play" type="button" aria-label="Play">
<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 Making badges with CSS only</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);
}
}// 실제 동작: Play 버튼을 누르면 아이콘이 바뀌고 파형 애니메이션이 실제로 멈췄다 다시 움직인다.
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 ? 'Pause' : 'Play');
});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 · Podcast player embed widget — Measured area 0.083% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Use it for an episode card inside a post, or a latest-episode widget in the sidebar.
.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); }
}
Clicking play really stops and restarts the wave — one line of animation-play-state ties playback state to the animation itself.
07GitHub repo stats card embed
What you see
The star icon wiggles side to side as if inviting a click.

<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 embed widgets — 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 · GitHub repo stats card embed — Measured area 0.198% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It never calls the real GitHub API, so it works as a repo card in an open-source write-up or a code link on a portfolio page.
.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); }
}
A real click on Star bumps the count and fills the icon's color.
08Newsletter signup embed widget
What happens on screen
The envelope icon floats up and settles down on a loop.

<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">Get an email every time a new demo drops</p>
<div class="ncard__row">
<label class="ncard__label" for="ncard-email-234-08">Email</label>
<input class="ncard__input" id="ncard-email-234-08" type="email" placeholder="[email protected]" required>
<button class="ncard__btn" type="submit">Subscribe</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;
}
}// 실제 동작: Email 형식을 검사해 통과하면 Done 상태로 바뀐다 (실제 전송은 없는 데모).
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 = 'Check the email format'; msg.className = 'ncard__msg is-error'; return; }
input.disabled = true;
btn.disabled = true;
btn.textContent = 'Done';
mail.hidden = true;
sent.hidden = false;
msg.textContent = 'Subscribed — a confirmation email is on its way';
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 · Newsletter signup embed widget — Measured area 0.66% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Use it as a subscribe card at the bottom of a post, or a newsletter form in the sidebar.
.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; }
}
A real regex check on submit swaps the icon to a paper plane and shows a completion message.
09Live chat bubble embed launcher
What moves
A white ring around the launcher spreads like radar while the badge number breathes bigger and smaller.

<main class="stage is-demo">
<div class="lchat">
<div class="lchat__panel" hidden>
<header class="lchat__head">
<span>Live chat</span>
<button class="lchat__close" type="button" aria-label="Close">
<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">Hi! How can we help?</p>
<div class="lchat__input-row">
<input class="lchat__input" type="text" placeholder="Type a message" aria-label="Message">
<button class="lchat__send" type="button">Send</button>
</div>
</div>
<button class="lchat__launcher" type="button" aria-label="Open chat" 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 · Live chat bubble embed launcher — Measured area 2.226% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Use it as a fixed contact button in the corner of a site, or a live-support entry point on a checkout page.
.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); }
}
A real click opens the chat panel, and .focus({ preventScroll: true }) moves focus to the input without ever forcing the page to scroll.
Where it breaks — cards overflowing at 320px
The X post card (03) and the testimonial card (04) first shipped at a 280px width with generous padding. Rendering at the 320px phone width from 블로그설정.json, document.body.scrollHeight came in 14px and 2px over the 200px budget — checking only document.documentElement misses that overflow entirely, since the stage CSS sets overflow: hidden and never shows a scrollbar. Shrinking the card to 256px and dropping the padding and gaps one spacing token at a time ($sp-4 to $sp-3, $sp-3 to $sp-2) landed exactly at 200px. The map card (05) needed a bigger cut — its map area went from 120px tall to 88px. Border radius is locked to $r-* tokens only, and every gap/padding/margin px value has to be a multiple of 4, so the 1–2px nudge on the play icon had to become transform: translateX() instead of margin-left. When an embed card has to draw numbers, the coordinate math from CSS Chart Design drops straight in. The password for all nine zips is xc2tmqdq — lowercase letters and one digit, so it pastes in exactly as shown, no shift key needed.
Accessibility
All nine drop their looping animation under prefers-reduced-motion: reduce and settle on a still shape — the play ring (02) and the chat-bubble ring (09) both lock to opacity: 0 so no ring is left floating mid-loop. Every button carries aria-label, aria-pressed, or aria-expanded so a screen reader can tell whether a like was pressed or a panel opened, and the chat bubble's input focus always passes { preventScroll: true } so the page never jumps. See MDN's prefers-reduced-motion reference for where a visitor turns this system setting on.
FAQ
Do these cards, or the social share buttons html here, actually load real YouTube, X, or Maps content?
No. All nine are CSS-and-vanilla-JS mockups that never call a real API or iframe. The video card (02) only fills a progress bar when clicked, and the X card's (03) like and repost counts only ever move inside that one card. If you need the real embed, drop the service's official embed code into the same spot and use this CSS as the placeholder before it loads. More embed patterns are searchable on the site search page. The strip these widgets usually end up living in is taken apart in Website Footer Design.
Can I restyle these for my own brand?
Yes. For most demos, changing just the three variables at the top of the SCSS — $duration, $easing, $color — shifts every animated color at once. Text like the company name, address, or quote lives right in the index.html string and can be edited directly; the React version takes the same values as props such as title, address, or quote.
How do I check for 320px overflow myself?
Open dev tools, narrow the viewport to 320px, and compare document.documentElement.scrollWidth and document.body.scrollWidth against window.innerWidth in the console. Checking documentElement alone hides the kind of overflow this post's trap section ran into behind overflow: hidden. See the about page for how this site builds and verifies every demo.









