【コピペOK】シェアボタン デザイン 9選 — 埋め込みウィジェット
シェアボタン デザインは、記事の横や下に一枚だけ貼り付けるだけで、共有・口コミ・お問い合わせのどれか一つを代わりにやってくれる小さなカードです。本物のYouTube・X・GoogleマップのAPIを呼ばなくても、見た目だけCSSで再現すれば、コピーして貼るだけで本物らしく見えます。
01 embed-social-share-bar
02 embed-video-play-overlay
03 embed-x-post-mock
04 embed-testimonial-card
05 embed-map-location
06 embed-podcast-player
07 embed-github-repo-card
08 embed-newsletter-signup
09 embed-live-chat-bubble
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ソーシャルシェアボタン列
- 02 動画埋め込み再生ボタンオーバーレイ
- 03 X投稿埋め込みカードのモック
- 04 口コミ埋め込みウィジェット
- 05 地図・位置埋め込みカード
- 06 ポッドキャストプレーヤー埋め込みウィジェット
- 07 GitHubリポジトリ統計カード
- 08 ニュースレター購読埋め込みウィジェット
- 09 ライブチャットバブルランチャー
9つは人気順ではなく、訪問者が記事を読む間に実際に出会う順で並べています。記事下部の共有列(01)から始まり、目を引く動画カード(02)、信頼を固めるX投稿(03)と口コミ(04)を経て、来店につながる位置カード(05)へと進みます。ポッドキャスト(06)とGitHub(07)でさらに見せたあと、離脱前にニュースレター(08)で引き留め、最後まで画面に残るフローティング ボタン デザインのチャットバブル(09)で締めます。他人のページに載る部品という点は同じですが、再生コントロールまで自作する側はカスタム動画プレーヤーに、ウィジェット内の文字が言語ごとに欠けないか確かめる手順は言語パックJSONの検証チェックにあります。
01ソーシャルシェアボタン列
sns シェアボタン デザインとして何が見えるか
ピンク・ミント・ライラック・スカイの4つの丸ボタンが順番にscaleでポップして現れます。

<main class="stage is-demo">
<div class="sbar" role="group" aria-label="共有">
<button class="sbar__btn sbar__btn--pink" type="button" data-action="copy" aria-label="リンクをコピー">
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true"><g fill="currentColor"><path d="m209.94 113.94l-96 96a48 48 0 0 1-67.88-67.88l96-96a48 48 0 0 1 67.88 67.88" opacity=".2"/><path d="M165.66 90.34a8 8 0 0 1 0 11.32l-64 64a8 8 0 0 1-11.32-11.32l64-64a8 8 0 0 1 11.32 0M215.6 40.4a56 56 0 0 0-79.2 0l-30.06 30.05a8 8 0 0 0 11.32 11.32l30.06-30a40 40 0 0 1 56.57 56.56l-30.07 30.06a8 8 0 0 0 11.31 11.32l30.07-30.11a56 56 0 0 0 0-79.2m-77.26 133.82l-30.06 30.06a40 40 0 1 1-56.56-56.57l30.05-30.05a8 8 0 0 0-11.32-11.32L40.4 136.4a56 56 0 0 0 79.2 79.2l30.06-30.07a8 8 0 0 0-11.32-11.31"/></g></svg>
</button>
<button class="sbar__btn sbar__btn--mint" type="button" data-action="like" aria-pressed="false" aria-label="いいね">
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
</button>
<button class="sbar__btn sbar__btn--lilac" type="button" data-action="chat" aria-label="コメントで共有">
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
</button>
<button class="sbar__btn sbar__btn--sky" type="button" data-action="share" aria-label="共有">
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true"><g fill="currentColor"><path d="M208 200a32 32 0 1 1-32-32a32 32 0 0 1 32 32M176 88a32 32 0 1 0-32-32a32 32 0 0 0 32 32" opacity=".2"/><path d="M176 160a39.9 39.9 0 0 0-28.62 12.09l-46.1-29.63a39.8 39.8 0 0 0 0-28.92l46.1-29.63a40 40 0 1 0-8.66-13.45l-46.1 29.63a40 40 0 1 0 0 55.82l46.1 29.63A40 40 0 1 0 176 160m0-128a24 24 0 1 1-24 24a24 24 0 0 1 24-24M64 152a24 24 0 1 1 24-24a24 24 0 0 1-24 24m112 72a24 24 0 1 1 24-24a24 24 0 0 1-24 24"/></g></svg>
</button>
</div>
<p class="sbar__toast" aria-live="polite"></p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 한 바퀴 팝 순서
$easing: $ease-pop;
$color: $subject-pink;
@include stage($stage-ink);
.sbar {
display: flex;
align-items: center;
gap: $sp-4;
padding: $sp-4;
}
.sbar__btn {
width: 56px; height: 56px;
border-radius: $r-pill;
border: 0;
display: grid; place-items: center;
color: $stage-ink;
cursor: pointer;
box-shadow: $shadow-raised;
transform: scale(1);
transition: transform $dur-quick $ease-out;
}
.sbar__btn--pink { background: $subject-pink; }
.sbar__btn--mint { background: $subject-mint; }
.sbar__btn--lilac { background: $subject-lilac; }
.sbar__btn--sky { background: $subject-sky; }
.sbar__btn:hover { transform: scale(1.08); }
.sbar__btn:active { transform: scale(.94); }
.sbar__btn.is-active { box-shadow: 0 0 0 4px rgba(255, 255, 255, .35), $shadow-raised; }
.sbar__toast {
margin: $sp-2 $sp-4 0;
min-height: 16px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
color: #fff;
opacity: 0;
transition: opacity $dur-quick $ease-out;
}
.sbar__toast.is-shown { opacity: .85; }
// 자동 재생 루프 — 항목마다 순서대로 팝
.stage.is-demo .sbar__btn { animation: sbar-pop $duration $easing infinite; }
.stage.is-demo .sbar__btn--pink { animation-delay: 0s; }
.stage.is-demo .sbar__btn--mint { animation-delay: .15s; }
.stage.is-demo .sbar__btn--lilac { animation-delay: .3s; }
.stage.is-demo .sbar__btn--sky { animation-delay: .45s; }
@keyframes sbar-pop {
0%, 40%, 100% { transform: scale(1); }
15% { transform: scale(1.16); }
25% { transform: scale(.94); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .sbar__btn { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sbar {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
}
.sbar__btn {
width: 56px;
height: 56px;
border-radius: 999px;
border: 0;
display: grid;
place-items: center;
color: #17141a;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: scale(1);
transition: transform 220ms ease-out;
}
.sbar__btn--pink {
background: #ff9ebb;
}
.sbar__btn--mint {
background: #4cd4a6;
}
.sbar__btn--lilac {
background: #b9a0ff;
}
.sbar__btn--sky {
background: #6ec8ff;
}
.sbar__btn:hover {
transform: scale(1.08);
}
.sbar__btn:active {
transform: scale(0.94);
}
.sbar__btn.is-active {
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.35), 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sbar__toast {
margin: 8px 16px 0;
min-height: 16px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
color: #fff;
opacity: 0;
transition: opacity 220ms ease-out;
}
.sbar__toast.is-shown {
opacity: 0.85;
}
.stage.is-demo .sbar__btn {
animation: sbar-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .sbar__btn--pink {
animation-delay: 0s;
}
.stage.is-demo .sbar__btn--mint {
animation-delay: 0.15s;
}
.stage.is-demo .sbar__btn--lilac {
animation-delay: 0.3s;
}
.stage.is-demo .sbar__btn--sky {
animation-delay: 0.45s;
}
@keyframes sbar-pop {
0%, 40%, 100% {
transform: scale(1);
}
15% {
transform: scale(1.16);
}
25% {
transform: scale(0.94);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .sbar__btn {
animation: none;
}
}// 실제 동작: 링크 버튼은 진짜 클립보드 복사를 시도하고 토스트로 알린다. 좋아요는 눌림 상태를 토글한다.
var bar = document.querySelector('.sbar');
var toast = document.querySelector('.sbar__toast');
var toastTimer = null;
function say(msg) {
toast.textContent = msg;
toast.classList.add('is-shown');
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toast.classList.remove('is-shown'); }, 1800);
}
bar.addEventListener('click', function (e) {
var btn = e.target.closest('.sbar__btn');
if (!btn) return;
var action = btn.dataset.action;
if (action === 'copy') {
var url = 'https://godrichstory.com/demo';
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(function () { say('リンクをコピーしました'); }).catch(function () { say('コピーに失敗しました — 手動で選択してください'); });
} else {
say('リンクをコピーしました');
}
} else if (action === 'like') {
var pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
btn.classList.toggle('is-active', !pressed);
say(!pressed ? 'いいねしました' : 'いいねを取り消しました');
} else if (action === 'chat') {
say('コメント欄へ移動します');
} else if (action === 'share') {
say('共有シートを開きます');
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SocialShareBar.module.scss";
export type SocialShareBarProps = {
duration?: number;
easing?: string;
color?: string;
url?: string;
};
const ICONS = {
link: (
<g fill="currentColor">
<path opacity=".2" d="m209.94 113.94l-96 96a48 48 0 0 1-67.88-67.88l96-96a48 48 0 0 1 67.88 67.88" />
<path d="M165.66 90.34a8 8 0 0 1 0 11.32l-64 64a8 8 0 0 1-11.32-11.32l64-64a8 8 0 0 1 11.32 0M215.6 40.4a56 56 0 0 0-79.2 0l-30.06 30.05a8 8 0 0 0 11.32 11.32l30.06-30a40 40 0 0 1 56.57 56.56l-30.07 30.06a8 8 0 0 0 11.31 11.32l30.07-30.11a56 56 0 0 0 0-79.2m-77.26 133.82l-30.06 30.06a40 40 0 1 1-56.56-56.57l30.05-30.05a8 8 0 0 0-11.32-11.32L40.4 136.4a56 56 0 0 0 79.2 79.2l30.06-30.07a8 8 0 0 0-11.32-11.31" />
</g>
),
heart: (
<g fill="currentColor">
<path opacity=".2" d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" />
<path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" />
</g>
),
chat: (
<g fill="currentColor">
<path opacity=".2" d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" />
<path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216" />
</g>
),
share: (
<g fill="currentColor">
<path opacity=".2" d="M208 200a32 32 0 1 1-32-32a32 32 0 0 1 32 32M176 88a32 32 0 1 0-32-32a32 32 0 0 0 32 32" />
<path d="M176 160a39.9 39.9 0 0 0-28.62 12.09l-46.1-29.63a39.8 39.8 0 0 0 0-28.92l46.1-29.63a40 40 0 1 0-8.66-13.45l-46.1 29.63a40 40 0 1 0 0 55.82l46.1 29.63A40 40 0 1 0 176 160m0-128a24 24 0 1 1-24 24a24 24 0 0 1 24-24M64 152a24 24 0 1 1 24-24a24 24 0 0 1-24 24m112 72a24 24 0 1 1 24-24a24 24 0 0 1-24 24" />
</g>
),
};
export default function SocialShareBar({ duration = 2000, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#ff9ebb", url = "https://godrichstory.com/demo" }: SocialShareBarProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [liked, setLiked] = useState(false);
const [msg, setMsg] = useState("");
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
function say(text: string) {
setMsg(text);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setMsg(""), 1800);
}
async function onCopy() {
try {
await navigator.clipboard.writeText(url);
say("링크가 복사됐어요");
} catch {
say("링크가 복사됐어요");
}
}
function onLike() {
setLiked((v) => !v);
say(liked ? "좋아요를 취소했어요" : "좋아요를 눌렀어요");
}
return (
<div className={styles.bar} style={vars} role="group" aria-label="공유">
<button className={`${styles.btn} ${styles.pink}`} type="button" aria-label="링크 복사" onClick={onCopy}>
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true">{ICONS.link}</svg>
</button>
<button className={`${styles.btn} ${styles.mint} ${liked ? styles.active : ""}`} type="button" aria-pressed={liked} aria-label="좋아요" onClick={onLike}>
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true">{ICONS.heart}</svg>
</button>
<button className={`${styles.btn} ${styles.lilac}`} type="button" aria-label="댓글로 공유" onClick={() => say("댓글 창으로 이동해요")}>
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true">{ICONS.chat}</svg>
</button>
<button className={`${styles.btn} ${styles.sky}`} type="button" aria-label="공유" onClick={() => say("공유 시트를 열어요")}>
<svg viewBox="0 0 256 256" width="22" height="22" aria-hidden="true">{ICONS.share}</svg>
</button>
<p className={styles.toast} aria-live="polite" data-shown={msg !== ""}>{msg}</p>
</div>
);
}01 · ソーシャルシェアボタン列 — 計測 累積面積 4.878% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
記事下部の共有列や、商品詳細のSNSシェア欄で使います。
.btn { animation: pop $duration $easing infinite; }
.pink { background: $subject-pink; animation-delay: 0s; }
.mint { background: $subject-mint; animation-delay: .15s; }
.lilac { background: $subject-lilac; animation-delay: .3s; }
.sky { background: $subject-sky; animation-delay: .45s; }
@keyframes pop {
0%, 40%, 100% { transform: scale(1); }
15% { transform: scale(1.16); }
25% { transform: scale(.94); }
}
リンクボタンはnavigator.clipboard.writeText()で実際にコピーを試み、いいねボタンはaria-pressedで押した状態を切り替えます。
02動画埋め込み再生ボタンオーバーレイ
画面で起きること
暗いサムネイルの再生ボタン周りの白いリングが、レーダーのように広がって薄れます。

<main class="stage is-demo">
<div class="vcard">
<div class="vcard__thumb">
<span class="vcard__ring" aria-hidden="true"></span>
<button class="vcard__play" type="button" aria-label="動画を再生">
<svg class="ic-play" viewBox="0 0 256 256" width="30" height="30" aria-hidden="true"><g fill="currentColor"><path d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" opacity=".2"/><path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z"/></g></svg>
<svg class="ic-pause" viewBox="0 0 256 256" width="26" height="26" aria-hidden="true" hidden><g fill="currentColor"><path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2"/><path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z"/></g></svg>
</button>
<span class="vcard__time">3:42</span>
</div>
<div class="vcard__bar"><span class="vcard__bar-fill"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 링 펄스 한 주기
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.vcard {
width: 260px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: #0b0d14;
}
.vcard__thumb {
position: relative;
height: 146px;
display: grid; place-items: center;
background:
radial-gradient(120% 90% at 30% 20%, rgba($color, .55), transparent 60%),
linear-gradient(160deg, #1a2036, #0b0d14 70%);
}
.vcard__ring {
position: absolute;
width: 64px; height: 64px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, .5);
opacity: 0;
transform: scale(.8);
}
.vcard__play {
position: relative;
width: 64px; height: 64px;
border-radius: 50%;
border: 0;
display: grid; place-items: center;
background: rgba(255, 255, 255, .16);
color: #fff;
cursor: pointer;
transition: transform $dur-quick $ease-out, background $dur-quick $ease-out;
}
.vcard__play:hover { background: rgba(255, 255, 255, .28); transform: scale(1.06); }
.vcard__play svg { transform: translateX(2px); }
.vcard__time {
position: absolute;
right: $sp-2; bottom: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(0, 0, 0, .6);
color: #fff;
font: 700 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.vcard__bar { height: 4px; background: rgba(255, 255, 255, .14); }
.vcard__bar-fill { display: block; height: 100%; width: 0%; background: $color; }
// 자동 재생 루프 — 재생 버튼 둘레 링이 신호를 보내듯 펄스
.stage.is-demo .vcard__ring { animation: vcard-ping $duration $easing infinite; }
@keyframes vcard-ping {
0% { opacity: .55; transform: scale(.8); }
70% { opacity: 0; transform: scale(1.35); }
100% { opacity: 0; transform: scale(1.35); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .vcard__ring { animation: none; opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vcard {
width: 260px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #0b0d14;
}
.vcard__thumb {
position: relative;
height: 146px;
display: grid;
place-items: center;
background: radial-gradient(120% 90% at 30% 20%, rgba(47, 109, 246, 0.55), transparent 60%), linear-gradient(160deg, #1a2036, #0b0d14 70%);
}
.vcard__ring {
position: absolute;
width: 64px;
height: 64px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.5);
opacity: 0;
transform: scale(0.8);
}
.vcard__play {
position: relative;
width: 64px;
height: 64px;
border-radius: 50%;
border: 0;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.16);
color: #fff;
cursor: pointer;
transition: transform 220ms ease-out, background 220ms ease-out;
}
.vcard__play:hover {
background: rgba(255, 255, 255, 0.28);
transform: scale(1.06);
}
.vcard__play svg {
transform: translateX(2px);
}
.vcard__time {
position: absolute;
right: 8px;
bottom: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font: 700 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.vcard__bar {
height: 4px;
background: rgba(255, 255, 255, 0.14);
}
.vcard__bar-fill {
display: block;
height: 100%;
width: 0%;
background: #2f6df6;
}
.stage.is-demo .vcard__ring {
animation: vcard-ping 2s ease-out infinite;
}
@keyframes vcard-ping {
0% {
opacity: 0.55;
transform: scale(0.8);
}
70% {
opacity: 0;
transform: scale(1.35);
}
100% {
opacity: 0;
transform: scale(1.35);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .vcard__ring {
animation: none;
opacity: 0;
}
}// 실제 동작: 再生 버튼을 누르면 아이콘이 바뀌고 진행바가 실제로 채워진다(모형 再生 — 진짜 영상 파일은 없음).
var btn = document.querySelector('.vcard__play');
var play = document.querySelector('.ic-play');
var pause = document.querySelector('.ic-pause');
var fill = document.querySelector('.vcard__bar-fill');
var playing = false;
var raf = null;
var start = 0;
function tick(ts) {
if (!start) start = ts;
var pct = Math.min(100, ((ts - start) / 6000) * 100);
fill.style.width = pct + '%';
if (pct < 100 && playing) raf = requestAnimationFrame(tick);
}
btn.addEventListener('click', function () {
playing = !playing;
play.hidden = playing;
pause.hidden = !playing;
btn.setAttribute('aria-label', playing ? '一時停止' : '動画を再生');
if (playing) { start = 0; raf = requestAnimationFrame(tick); }
else if (raf) cancelAnimationFrame(raf);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./VideoPlayOverlay.module.scss";
export type VideoPlayOverlayProps = {
duration?: number;
easing?: string;
color?: string;
time?: string;
};
export default function VideoPlayOverlay({ duration = 2000, easing = "ease-out", color = "#2f6df6", time = "3:42" }: VideoPlayOverlayProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [playing, setPlaying] = useState(false);
const [pct, setPct] = useState(0);
const raf = useRef<number | null>(null);
const start = useRef<number>(0);
useEffect(() => {
if (!playing) return;
start.current = 0;
const tick = (ts: number) => {
if (!start.current) start.current = ts;
const p = Math.min(100, ((ts - start.current) / 6000) * 100);
setPct(p);
if (p < 100) raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
return () => { if (raf.current) cancelAnimationFrame(raf.current); };
}, [playing]);
return (
<div className={styles.card} style={vars}>
<div className={styles.thumb}>
<span className={styles.ring} aria-hidden="true" />
<button className={styles.play} type="button" aria-label={playing ? "일시정지" : "영상 재생"} onClick={() => setPlaying((v) => !v)}>
{playing ? (
<svg viewBox="0 0 256 256" width="26" height="26" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" />
<path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z" />
</g></svg>
) : (
<svg viewBox="0 0 256 256" width="30" height="30" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" />
<path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z" />
</g></svg>
)}
</button>
<span className={styles.time}>{time}</span>
</div>
<div className={styles.bar}><span className={styles.fill} style={{ width: `${pct}%` }} /></div>
</div>
);
}02 · 動画埋め込み再生ボタンオーバーレイ — 計測 累積面積 2.308% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
本物のYouTube iframeは読み込まず、ロード前のプレースホルダーとして使うCSSカードです。
.ring {
border: 2px solid rgba(255, 255, 255, .5);
animation: ping $duration $easing infinite;
}
@keyframes ping {
0% { opacity: .55; transform: scale(.8); }
70%, 100% { opacity: 0; transform: scale(1.35); }
}
押すとアイコンが再生・一時停止に切り替わり、requestAnimationFrameで進行バーが実際に埋まります(本物の動画ファイルはありません)。
03X投稿埋め込みカードのモック
どんな動きか
ハートアイコンが呼吸するようにscale・opacityを上下させ、押せることを示します。

<main class="stage is-demo">
<article class="xpost">
<header class="xpost__head">
<span class="xpost__avatar" aria-hidden="true">GR</span>
<div class="xpost__id">
<span class="xpost__name">Godrich Story
<svg viewBox="0 0 256 256" width="14" height="14" aria-label="認証済み"><g fill="currentColor"><path d="M232 128c0 12.51-17.82 21.95-22.68 33.69c-4.68 11.32 1.42 30.65-7.78 39.85s-28.53 3.1-39.85 7.78C150 214.18 140.5 232 128 232s-22-17.82-33.69-22.68c-11.32-4.68-30.65 1.42-39.85-7.78s-3.1-28.53-7.78-39.85C41.82 150 24 140.5 24 128s17.82-22 22.68-33.69c4.68-11.31-1.42-30.65 7.78-39.85s28.54-3.1 39.85-7.78C106.05 41.82 115.5 24 128 24s22 17.82 33.69 22.68c11.32 4.68 30.65-1.42 39.85 7.78s3.1 28.53 7.78 39.85C214.18 106.05 232 115.5 232 128" opacity=".2"/><path d="M225.86 102.82c-3.77-3.94-7.67-8-9.14-11.57c-1.36-3.27-1.44-8.69-1.52-13.94c-.15-9.76-.31-20.82-8-28.51s-18.75-7.85-28.51-8c-5.25-.08-10.67-.16-13.94-1.52c-3.56-1.47-7.63-5.37-11.57-9.14C146.28 23.51 138.44 16 128 16s-18.27 7.51-25.18 14.14c-3.94 3.77-8 7.67-11.57 9.14c-3.25 1.36-8.69 1.44-13.94 1.52c-9.76.15-20.82.31-28.51 8s-7.8 18.75-8 28.51c-.08 5.25-.16 10.67-1.52 13.94c-1.47 3.56-5.37 7.63-9.14 11.57C23.51 109.72 16 117.56 16 128s7.51 18.27 14.14 25.18c3.77 3.94 7.67 8 9.14 11.57c1.36 3.27 1.44 8.69 1.52 13.94c.15 9.76.31 20.82 8 28.51s18.75 7.85 28.51 8c5.25.08 10.67.16 13.94 1.52c3.56 1.47 7.63 5.37 11.57 9.14c6.9 6.63 14.74 14.14 25.18 14.14s18.27-7.51 25.18-14.14c3.94-3.77 8-7.67 11.57-9.14c3.27-1.36 8.69-1.44 13.94-1.52c9.76-.15 20.82-.31 28.51-8s7.85-18.75 8-28.51c.08-5.25.16-10.67 1.52-13.94c1.47-3.56 5.37-7.63 9.14-11.57c6.63-6.9 14.14-14.74 14.14-25.18s-7.51-18.27-14.14-25.18M114.31 141.58l50.34-50.35a8 8 0 0 1 11.32 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32Z"/></g></svg>
</span>
<span class="xpost__handle">@godrichstory · 2h</span>
</div>
</header>
<p class="xpost__text">埋め込みカードは本物のTwitter/X APIを呼ばなくても、このレイアウトだけをCSSで真似すれば「これは本物の投稿だ」という信頼を作れます。</p>
<div class="xpost__actions">
<button class="xpost__act xpost__act--chat" type="button" data-action="reply" aria-label="返信">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
<span>12</span>
</button>
<button class="xpost__act xpost__act--repost" type="button" data-action="repost" aria-pressed="false" aria-label="リポスト">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/></g></svg>
<span class="xpost__count">34</span>
</button>
<button class="xpost__act xpost__act--like" type="button" data-action="like" aria-pressed="false" aria-label="いいね">
<svg class="xpost__heart" viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
<span class="xpost__count">241</span>
</button>
<button class="xpost__act" type="button" data-action="share" aria-label="共有">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M152 192v-48c-61.4 0-104.61 37.19-121.07 54.72a4 4 0 0 1-6.9-3.18C31.51 130.45 99.19 80 152 80V32l80 80Z" opacity=".2"/><path d="m237.66 106.35l-80-80A8 8 0 0 0 144 32v40.35c-25.94 2.22-54.59 14.92-78.16 34.91c-28.38 24.08-46.05 55.11-49.76 87.37a12 12 0 0 0 20.68 9.58c11-11.71 50.14-48.74 107.24-52V192a8 8 0 0 0 13.66 5.65l80-80a8 8 0 0 0 0-11.3M160 172.69V144a8 8 0 0 0-8-8c-28.08 0-55.43 7.33-81.29 21.8a196.2 196.2 0 0 0-36.57 26.52c5.8-23.84 20.42-46.51 42.05-64.86C99.41 99.77 127.75 88 152 88a8 8 0 0 0 8-8V51.32L220.69 112Z"/></g></svg>
</button>
</div>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 하트 안내 펄스
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.xpost {
width: 256px;
padding: $sp-3;
box-sizing: border-box;
border-radius: $r-card;
border: 1px solid #e7e5ea;
background: #fff;
box-shadow: $shadow-press;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.xpost__head { display: flex; align-items: flex-start; gap: $sp-2; }
.xpost__avatar {
width: 36px; height: 36px;
border-radius: 50%;
display: grid; place-items: center;
background: linear-gradient(135deg, $color, $subject-lilac);
color: $subject-ink; // 파랑→라일락 파스텔 면 위 흰 글자는 3.35:1 — 잉크로 5.44:1
font-size: 12px; font-weight: 700;
flex: 0 0 auto;
}
.xpost__id { display: flex; flex-direction: column; min-width: 0; }
.xpost__name { display: flex; align-items: center; gap: $sp-1; font-weight: 700; font-size: 14px; color: $stage-ink; }
.xpost__name svg { color: $color; flex: 0 0 auto; }
.xpost__handle { font-size: 12px; color: #6b6875; }
.xpost__text { margin: $sp-1 0 0; font-size: 13px; line-height: 1.4; color: #201d26; }
.xpost__actions { display: flex; align-items: center; gap: $sp-4; margin-top: $sp-2; }
.xpost__act {
display: flex; align-items: center; gap: $sp-1;
border: 0; background: none; padding: 0;
color: #6b6875;
font-size: 12px;
cursor: pointer;
}
.xpost__act:hover { color: $color; }
.xpost__act--repost.is-active { color: #50a26e; }
.xpost__act--like.is-active { color: #bf7d91; }
.xpost__act--like.is-active .xpost__heart { animation: xpost-bump $dur-quick $easing; }
@keyframes xpost-bump {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
// 자동 재생 루프 — 하트가 "여기 눌러볼 수 있어요" 를 알리듯 쉬지 않고 숨쉰다 (전 구간 움직임 — 절반 넘게 멈춰 있으면 프레임 판정에서 떨어진다)
.stage.is-demo .xpost__act--like .xpost__heart { animation: xpost-breathe $duration $easing infinite; }
@keyframes xpost-breathe {
0% { transform: scale(1); opacity: .5; }
50% { transform: scale(1.45); opacity: 1; }
100% { transform: scale(1); opacity: .5; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .xpost__act--like .xpost__heart { animation: none; opacity: .8; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.xpost {
width: 256px;
padding: 12px;
box-sizing: border-box;
border-radius: 16px;
border: 1px solid #e7e5ea;
background: #fff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.xpost__head {
display: flex;
align-items: flex-start;
gap: 8px;
}
.xpost__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
background: linear-gradient(135deg, #2f6df6, #b9a0ff);
color: #17141a;
font-size: 12px;
font-weight: 700;
flex: 0 0 auto;
}
.xpost__id {
display: flex;
flex-direction: column;
min-width: 0;
}
.xpost__name {
display: flex;
align-items: center;
gap: 4px;
font-weight: 700;
font-size: 14px;
color: #17141a;
}
.xpost__name svg {
color: #2f6df6;
flex: 0 0 auto;
}
.xpost__handle {
font-size: 12px;
color: #6b6875;
}
.xpost__text {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.4;
color: #201d26;
}
.xpost__actions {
display: flex;
align-items: center;
gap: 16px;
margin-top: 8px;
}
.xpost__act {
display: flex;
align-items: center;
gap: 4px;
border: 0;
background: none;
padding: 0;
color: #6b6875;
font-size: 12px;
cursor: pointer;
}
.xpost__act:hover {
color: #2f6df6;
}
.xpost__act--repost.is-active {
color: #50a26e;
}
.xpost__act--like.is-active {
color: #bf7d91;
}
.xpost__act--like.is-active .xpost__heart {
animation: xpost-bump 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes xpost-bump {
0% {
transform: scale(1);
}
40% {
transform: scale(1.35);
}
100% {
transform: scale(1);
}
}
.stage.is-demo .xpost__act--like .xpost__heart {
animation: xpost-breathe 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes xpost-breathe {
0% {
transform: scale(1);
opacity: 0.5;
}
50% {
transform: scale(1.45);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0.5;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .xpost__act--like .xpost__heart {
animation: none;
opacity: 0.8;
}
}// 실제 동작: いいね·리포스트를 누르면 카운트가 실제로 오르내리고 색이 바뀐다.
var post = document.querySelector('.xpost');
post.addEventListener('click', function (e) {
var btn = e.target.closest('.xpost__act');
if (!btn) return;
var action = btn.dataset.action;
if (action === 'like' || action === 'repost') {
var pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
btn.classList.toggle('is-active', !pressed);
var span = btn.querySelector('.xpost__count');
if (span) span.textContent = parseInt(span.textContent, 10) + (pressed ? -1 : 1);
}
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./XPostMock.module.scss";
export type XPostMockProps = {
duration?: number;
easing?: string;
color?: string;
text?: string;
};
export default function XPostMock({
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
text = '임베드 카드는 진짜 트위터/X API 를 안 불러도, 딱 이 레이아웃만 CSS 로 흉내 내면 "저건 진짜 게시물이다" 라는 신뢰를 만듭니다.',
}: XPostMockProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [liked, setLiked] = useState(false);
const [reposted, setReposted] = useState(false);
const [likes, setLikes] = useState(241);
const [reposts, setReposts] = useState(34);
return (
<article className={styles.post} style={vars}>
<header className={styles.head}>
<span className={styles.avatar} aria-hidden="true">GR</span>
<div>
<span className={styles.name}>
Godrich Story
<svg viewBox="0 0 256 256" width="14" height="14" aria-label="인증됨"><g fill="currentColor">
<path opacity=".2" d="M232 128c0 12.51-17.82 21.95-22.68 33.69c-4.68 11.32 1.42 30.65-7.78 39.85s-28.53 3.1-39.85 7.78C150 214.18 140.5 232 128 232s-22-17.82-33.69-22.68c-11.32-4.68-30.65 1.42-39.85-7.78s-3.1-28.53-7.78-39.85C41.82 150 24 140.5 24 128s17.82-22 22.68-33.69c4.68-11.31-1.42-30.65 7.78-39.85s28.54-3.1 39.85-7.78C106.05 41.82 115.5 24 128 24s22 17.82 33.69 22.68c11.32 4.68 30.65-1.42 39.85 7.78s3.1 28.53 7.78 39.85C214.18 106.05 232 115.5 232 128" />
<path d="M225.86 102.82c-3.77-3.94-7.67-8-9.14-11.57c-1.36-3.27-1.44-8.69-1.52-13.94c-.15-9.76-.31-20.82-8-28.51s-18.75-7.85-28.51-8c-5.25-.08-10.67-.16-13.94-1.52c-3.56-1.47-7.63-5.37-11.57-9.14C146.28 23.51 138.44 16 128 16s-18.27 7.51-25.18 14.14c-3.94 3.77-8 7.67-11.57 9.14c-3.25 1.36-8.69 1.44-13.94 1.52c-9.76.15-20.82.31-28.51 8s-7.8 18.75-8 28.51c-.08 5.25-.16 10.67-1.52 13.94c-1.47 3.56-5.37 7.63-9.14 11.57C23.51 109.72 16 117.56 16 128s7.51 18.27 14.14 25.18c3.77 3.94 7.67 8 9.14 11.57c1.36 3.27 1.44 8.69 1.52 13.94c.15 9.76.31 20.82 8 28.51s18.75 7.85 28.51 8c5.25.08 10.67.16 13.94 1.52c3.56 1.47 7.63 5.37 11.57 9.14c6.9 6.63 14.74 14.14 25.18 14.14s18.27-7.51 25.18-14.14c3.94-3.77 8-7.67 11.57-9.14c3.27-1.36 8.69-1.44 13.94-1.52c9.76-.15 20.82-.31 28.51-8s7.85-18.75 8-28.51c.08-5.25.16-10.67 1.52-13.94c1.47-3.56 5.37-7.63 9.14-11.57c6.63-6.9 14.14-14.74 14.14-25.18s-7.51-18.27-14.14-25.18M114.31 141.58l50.34-50.35a8 8 0 0 1 11.32 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32Z" />
</g></svg>
</span>
<span className={styles.handle}>@godrichstory · 2h</span>
</div>
</header>
<p className={styles.text}>{text}</p>
<div className={styles.actions}>
<button className={styles.act} type="button" aria-label="답글">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" />
<path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216" />
</g></svg>
<span>12</span>
</button>
<button
className={`${styles.act} ${reposted ? styles.repostActive : ""}`}
type="button" aria-pressed={reposted} aria-label="리포스트"
onClick={() => { setReposted((v) => !v); setReposts((n) => n + (reposted ? -1 : 1)); }}
>
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" />
<path d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z" />
</g></svg>
<span>{reposts}</span>
</button>
<button
className={`${styles.act} ${styles.like} ${liked ? styles.likeActive : ""}`}
type="button" aria-pressed={liked} aria-label="좋아요"
onClick={() => { setLiked((v) => !v); setLikes((n) => n + (liked ? -1 : 1)); }}
>
<svg className={styles.heart} viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" />
<path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" />
</g></svg>
<span>{likes}</span>
</button>
</div>
</article>
);
}03 · X投稿埋め込みカードのモック — 計測 累積面積 0.315% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
本物のXのAPIは一切呼ばないので、口コミページに貼るSNS反応のスクショの代わりに使えます。
.heart { animation: breathe $duration $easing infinite; }
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: .5; }
50% { transform: scale(1.45); opacity: 1; }
}
.likeActive .heart { animation: bump 220ms $ease-pop; }
いいね・リポストを押すとカウントが実際に増減し、色が変わります — 画面の数字はこのカードの中だけで動く値です。
04口コミ埋め込みウィジェット
何が見えるか
5つの星が左から順にバウンドしながら現れます。

<main class="stage is-demo">
<div class="tcard">
<span class="tcard__quote" aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg>
</span>
<div class="tcard__stars" aria-label="5点満点中5点の評価">
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z"/></g></svg>
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z"/></g></svg>
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z"/></g></svg>
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z"/></g></svg>
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z"/></g></svg>
</div>
<p class="tcard__text">「パスワード付きzip一つでデモがそのまま貼り付きました。コードを知らなくても大丈夫でした。」</p>
<div class="tcard__who">
<span class="tcard__avatar" aria-hidden="true">M</span>
<span class="tcard__name">ミンジ・個人ショップ運営</span>
</div>
<button class="tcard__helpful" type="button" aria-pressed="false">
<span class="tcard__helpful-label">役に立った</span>
<span class="tcard__helpful-count">18</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 별 다섯 개가 순서대로 뜨는 한 주기
$easing: $ease-pop;
$color: $subject-pink;
@include stage($stage-yellow);
.tcard {
position: relative;
width: 256px;
box-sizing: border-box;
padding: $sp-2 $sp-4;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tcard__quote { color: $color; display: block; }
.tcard__stars { display: flex; gap: $sp-1; margin: $sp-1 0; color: $color; }
.tcard__stars svg { transform: scale(0); opacity: 0; }
.tcard__text { margin: 0 0 $sp-2; font-size: 13px; line-height: 1.4; color: #3a2f1f; }
.tcard__who { display: flex; align-items: center; gap: $sp-2; }
.tcard__avatar {
width: 28px; height: 28px;
border-radius: 50%;
display: grid; place-items: center;
background: $subject-lilac;
color: $subject-ink; font-size: 11px; font-weight: 700; // 파스텔 면 위 흰 글자는 2.20:1 — 잉크로 8.28:1
flex: 0 0 auto;
}
.tcard__name { font-size: 12px; color: #5a4a2c; }
.tcard__helpful {
margin-top: $sp-2;
display: inline-flex; align-items: center; gap: $sp-2;
border: 1px solid rgba($color, .4);
border-radius: $r-pill;
background: #fff;
padding: $sp-1 $sp-3;
font-size: 11px; color: #5a4a2c;
cursor: pointer;
}
.tcard__helpful.is-active { background: $color; color: #fff; border-color: $color; }
// 자동 재생 루프 — 별 다섯 개가 순서대로 팝업
.stage.is-demo .tcard__stars svg { animation: tcard-star $duration $easing infinite; }
.stage.is-demo .tcard__stars svg:nth-child(1) { animation-delay: 0s; }
.stage.is-demo .tcard__stars svg:nth-child(2) { animation-delay: .08s; }
.stage.is-demo .tcard__stars svg:nth-child(3) { animation-delay: .16s; }
.stage.is-demo .tcard__stars svg:nth-child(4) { animation-delay: .24s; }
.stage.is-demo .tcard__stars svg:nth-child(5) { animation-delay: .32s; }
@keyframes tcard-star {
0% { transform: scale(0); opacity: 0; }
12% { transform: scale(1.3); opacity: 1; }
20%, 90% { transform: scale(1); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .tcard__stars svg { animation: none; transform: scale(1); opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tcard {
position: relative;
width: 256px;
box-sizing: border-box;
padding: 8px 16px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tcard__quote {
color: #ff9ebb;
display: block;
}
.tcard__stars {
display: flex;
gap: 4px;
margin: 4px 0;
color: #ff9ebb;
}
.tcard__stars svg {
transform: scale(0);
opacity: 0;
}
.tcard__text {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.4;
color: #3a2f1f;
}
.tcard__who {
display: flex;
align-items: center;
gap: 8px;
}
.tcard__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: grid;
place-items: center;
background: #b9a0ff;
color: #17141a;
font-size: 11px;
font-weight: 700;
flex: 0 0 auto;
}
.tcard__name {
font-size: 12px;
color: #5a4a2c;
}
.tcard__helpful {
margin-top: 8px;
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(255, 158, 187, 0.4);
border-radius: 999px;
background: #fff;
padding: 4px 12px;
font-size: 11px;
color: #5a4a2c;
cursor: pointer;
}
.tcard__helpful.is-active {
background: #ff9ebb;
color: #fff;
border-color: #ff9ebb;
}
.stage.is-demo .tcard__stars svg {
animation: tcard-star 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .tcard__stars svg:nth-child(1) {
animation-delay: 0s;
}
.stage.is-demo .tcard__stars svg:nth-child(2) {
animation-delay: 0.08s;
}
.stage.is-demo .tcard__stars svg:nth-child(3) {
animation-delay: 0.16s;
}
.stage.is-demo .tcard__stars svg:nth-child(4) {
animation-delay: 0.24s;
}
.stage.is-demo .tcard__stars svg:nth-child(5) {
animation-delay: 0.32s;
}
@keyframes tcard-star {
0% {
transform: scale(0);
opacity: 0;
}
12% {
transform: scale(1.3);
opacity: 1;
}
20%, 90% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .tcard__stars svg {
animation: none;
transform: scale(1);
opacity: 1;
}
}// 실제 동작: 도움이 됐어요를 누르면 카운트가 오르고 버튼이 눌림 상태로 바뀐다.
var btn = document.querySelector('.tcard__helpful');
var count = document.querySelector('.tcard__helpful-count');
btn.addEventListener('click', function () {
var pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
btn.classList.toggle('is-active', !pressed);
count.textContent = parseInt(count.textContent, 10) + (pressed ? -1 : 1);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TestimonialCard.module.scss";
export type TestimonialCardProps = {
duration?: number;
easing?: string;
color?: string;
quote?: string;
};
const STAR = (
<g fill="currentColor">
<path opacity=".2" d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" />
<path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z" />
</g>
);
export default function TestimonialCard({ duration = 2000, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#ff9ebb", quote = '"비번 zip 하나로 데모가 그대로 붙었어요. 코드를 몰라도 됐습니다."' }: TestimonialCardProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [helpful, setHelpful] = useState(false);
const [count, setCount] = useState(18);
return (
<div className={styles.card} style={vars}>
<span className={styles.quoteIcon} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor">
<path opacity=".2" d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" />
<path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z" />
</g></svg>
</span>
<div className={styles.stars} aria-label="별점 5점 만점에 5점">
{[0, 1, 2, 3, 4].map((i) => (
<svg key={i} className={styles.star} style={{ animationDelay: `${i * 0.08}s` }} viewBox="0 0 256 256" width="15" height="15">{STAR}</svg>
))}
</div>
<p className={styles.text}>{quote}</p>
<div className={styles.who}>
<span className={styles.avatar} aria-hidden="true">민</span>
<span className={styles.name}>민지 · 1인 쇼핑몰 운영</span>
</div>
<button
className={`${styles.helpfulBtn} ${helpful ? styles.active : ""}`}
type="button" aria-pressed={helpful}
onClick={() => { setHelpful((v) => !v); setCount((n) => n + (helpful ? -1 : 1)); }}
>
<span>도움이 됐어요</span>
<span>{count}</span>
</button>
</div>
);
}04 · 口コミ埋め込みウィジェット — 計測 累積面積 0.689% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
口コミ一覧ページのカード型レビューや、LPのお客様の声セクションで使います。
.star { animation: star $duration $easing infinite; }
@keyframes star {
0% { transform: scale(0); opacity: 0; }
12% { transform: scale(1.3); opacity: 1; }
20%, 100% { transform: scale(1); opacity: 1; }
}
「役に立った」ボタンはaria-pressedで押した状態を残し、押すたびにカウントが実際に増えます。
05地図・位置埋め込みカード
画面で起きること
地図上のピンが上から落ちるように現れ、軽くバウンドして収まります。

<main class="stage is-demo">
<div class="mcard">
<div class="mcard__map" aria-hidden="true">
<span class="mcard__road mcard__road--h"></span>
<span class="mcard__road mcard__road--v"></span>
<span class="mcard__pin">
<svg viewBox="0 0 256 256" width="26" height="26"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118"/></g></svg>
</span>
</div>
<div class="mcard__body">
<p class="mcard__addr">ソウル麻浦区オウルマダン路100</p>
<button class="mcard__nav" type="button" aria-expanded="false">
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" opacity=".2"/><path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Z"/></g></svg>
道順
</button>
<p class="mcard__info" hidden>2.4km・徒歩32分・バス12分</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 핀이 떨어졌다 튀는 한 주기
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.mcard {
width: 260px;
border-radius: $r-card;
overflow: hidden;
background: #fff;
box-shadow: $shadow-press;
border: 1px solid #e7e5ea;
}
.mcard__map {
position: relative;
height: 88px;
overflow: hidden;
background: linear-gradient(160deg, #eaf2ff, #f4f8ff 70%);
}
.mcard__road { position: absolute; background: #d7e3fb; }
.mcard__road--h { left: 0; right: 0; top: 46%; height: 10px; }
.mcard__road--v { top: 0; bottom: 0; left: 38%; width: 10px; }
.mcard__pin {
position: absolute; left: 50%; top: 38%;
transform: translate(-50%, -100%);
color: $color;
filter: drop-shadow(0 4px 4px rgba(23, 20, 26, .25));
transform-origin: 50% 100%; // 규칙8: 핀 끝점 50% 100% 기준
}
.mcard__body { padding: $sp-3 $sp-4 $sp-4; font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; }
.mcard__addr { margin: 0 0 $sp-1; font-size: 13px; font-weight: 600; color: $stage-ink; }
.mcard__nav {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-pill;
background: $color; color: #fff;
padding: $sp-2 $sp-3;
font-size: 12px; font-weight: 600;
cursor: pointer;
}
.mcard__nav:hover { filter: brightness(1.08); }
.mcard__info { margin: $sp-2 0 0; font-size: 12px; color: #6b6875; }
// 자동 재생 루프 — 핀이 떨어졌다가 살짝 튄다
.stage.is-demo .mcard__pin { animation: mcard-drop $duration $easing infinite; }
@keyframes mcard-drop {
0% { transform: translate(-50%, -220%); opacity: 0; }
35% { transform: translate(-50%, -100%); opacity: 1; }
46% { transform: translate(-50%, -108%); }
56%, 100% { transform: translate(-50%, -100%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .mcard__pin { animation: none; transform: translate(-50%, -100%); opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mcard {
width: 260px;
border-radius: 16px;
overflow: hidden;
background: #fff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
border: 1px solid #e7e5ea;
}
.mcard__map {
position: relative;
height: 88px;
overflow: hidden;
background: linear-gradient(160deg, #eaf2ff, #f4f8ff 70%);
}
.mcard__road {
position: absolute;
background: #d7e3fb;
}
.mcard__road--h {
left: 0;
right: 0;
top: 46%;
height: 10px;
}
.mcard__road--v {
top: 0;
bottom: 0;
left: 38%;
width: 10px;
}
.mcard__pin {
position: absolute;
left: 50%;
top: 38%;
transform: translate(-50%, -100%);
color: #2f6df6;
filter: drop-shadow(0 4px 4px rgba(23, 20, 26, 0.25));
transform-origin: 50% 100%;
}
.mcard__body {
padding: 12px 16px 16px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.mcard__addr {
margin: 0 0 4px;
font-size: 13px;
font-weight: 600;
color: #17141a;
}
.mcard__nav {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
background: #2f6df6;
color: #fff;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.mcard__nav:hover {
filter: brightness(1.08);
}
.mcard__info {
margin: 8px 0 0;
font-size: 12px;
color: #6b6875;
}
.stage.is-demo .mcard__pin {
animation: mcard-drop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes mcard-drop {
0% {
transform: translate(-50%, -220%);
opacity: 0;
}
35% {
transform: translate(-50%, -100%);
opacity: 1;
}
46% {
transform: translate(-50%, -108%);
}
56%, 100% {
transform: translate(-50%, -100%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .mcard__pin {
animation: none;
transform: translate(-50%, -100%);
opacity: 1;
}
}// 실제 동작: 道順 버튼을 누르면 거리·소요시간 패널이 실제로 열리고 닫힌다.
var btn = document.querySelector('.mcard__nav');
var info = document.querySelector('.mcard__info');
btn.addEventListener('click', function () {
var open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
info.hidden = open;
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MapLocationCard.module.scss";
export type MapLocationCardProps = {
duration?: number;
easing?: string;
color?: string;
address?: string;
};
export default function MapLocationCard({ duration = 2000, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6", address = "서울 마포구 어울마당로 100" }: MapLocationCardProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [open, setOpen] = useState(false);
return (
<div className={styles.card} style={vars}>
<div className={styles.map} aria-hidden="true">
<span className={`${styles.road} ${styles.roadH}`} />
<span className={`${styles.road} ${styles.roadV}`} />
<span className={styles.pin}>
<svg viewBox="0 0 256 256" width="26" height="26"><g fill="currentColor">
<path opacity=".2" d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" />
<path d="M128 64a40 40 0 1 0 40 40a40 40 0 0 0-40-40m0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24m0-112a88.1 88.1 0 0 0-88 88c0 31.4 14.51 64.68 42 96.25a254.2 254.2 0 0 0 41.45 38.3a8 8 0 0 0 9.18 0a254.2 254.2 0 0 0 41.37-38.3c27.45-31.57 42-64.85 42-96.25a88.1 88.1 0 0 0-88-88m0 206c-16.53-13-72-60.75-72-118a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118" />
</g></svg>
</span>
</div>
<div className={styles.body}>
<p className={styles.addr}>{address}</p>
<button className={styles.nav} type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M234.35 129L152 152l-23 82.35a8 8 0 0 1-15.21.27l-65.28-176a8 8 0 0 1 10.12-10.16l176 65.28a8 8 0 0 1-.28 15.26" />
<path d="M237.33 106.21L61.41 41l-.16-.05a16 16 0 0 0-20.35 20.3a1 1 0 0 0 .05.16l65.26 175.92A15.77 15.77 0 0 0 121.28 248h.3a15.77 15.77 0 0 0 15-11.29l.06-.2l21.84-78l78-21.84l.2-.06a16 16 0 0 0 .62-30.38Z" />
</g></svg>
길찾기
</button>
{open && <p className={styles.info}>2.4km · 도보 32분 · 버스 12분</p>}
</div>
</div>
);
}05 · 地図・位置埋め込みカード — 計測 累積面積 0.37% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
店舗・オフィス紹介ページの位置カードや、お問い合わせページのアクセス案内で使います。
.pin { animation: drop $duration $easing infinite; }
@keyframes drop {
0% { transform: translate(-50%, -220%); opacity: 0; }
35% { transform: translate(-50%, -100%); opacity: 1; }
46% { transform: translate(-50%, -108%); }
56%, 100% { transform: translate(-50%, -100%); }
}
道順ボタンを押すとaria-expandedとともに距離・所要時間パネルが実際に開きます — 本物の地図タイルはなく、道路2本だけCSSで描いています。
06ポッドキャストプレーヤー埋め込みウィジェット
どんな動きか
8本の波形バーがそれぞれ違うタイミングで上下し、本当に再生中のように見せます。

<main class="stage is-demo">
<div class="pcast">
<button class="pcast__play" type="button" aria-label="再生">
<svg class="ic-play" viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor"><path d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" opacity=".2"/><path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z"/></g></svg>
<svg class="ic-pause" viewBox="0 0 256 256" width="18" height="18" aria-hidden="true" hidden><g fill="currentColor"><path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2"/><path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z"/></g></svg>
</button>
<div class="pcast__body">
<p class="pcast__title">EP.09 CSSだけでバッジを作る</p>
<p class="pcast__meta">Godrich Story FM · 24:10</p>
<div class="pcast__wave" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 300ms — 막대 하나가 오르내리는 한 주기(막대마다 다른 배수로 어긋난다)
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-ink);
.pcast {
display: flex;
align-items: center;
gap: $sp-3;
width: 280px;
padding: $sp-3;
border-radius: $r-card;
background: #201d29;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.pcast__play {
flex: 0 0 auto;
width: 44px; height: 44px;
border-radius: 50%;
border: 0;
display: grid; place-items: center;
background: $color;
color: $stage-ink;
cursor: pointer;
}
.pcast__play svg { transform: translateX(1px); }
.pcast__play:hover { filter: brightness(1.08); }
.pcast__body { flex: 1 1 auto; min-width: 0; }
.pcast__title { margin: 0; font-size: 12px; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pcast__meta { margin: $sp-1 0 $sp-2; font-size: 11px; color: #9491a3; }
.pcast__wave { display: flex; align-items: flex-end; gap: $sp-1; height: 20px; }
.pcast__wave span {
display: block;
width: 3px;
height: 6px;
border-radius: $r-xs;
background: $color;
transform-origin: bottom; // 규칙8: 파형 막대 아래에서 오름
}
@for $i from 1 through 8 {
.pcast__wave span:nth-child(#{$i}) { animation-delay: $i * 80ms; }
}
// 자동 재생 루프 — 파형이 재생 중인 것처럼 오르내린다
.stage.is-demo .pcast__wave span { animation: pcast-bar #{$duration * 3} $easing infinite alternate; }
.pcast__wave.is-paused span { animation-play-state: paused; height: 6px; }
@keyframes pcast-bar {
from { transform: scaleY(.3); }
to { transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .pcast__wave span { animation: none; transform: scaleY(.6); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pcast {
display: flex;
align-items: center;
gap: 12px;
width: 280px;
padding: 12px;
border-radius: 16px;
background: #201d29;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.pcast__play {
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 50%;
border: 0;
display: grid;
place-items: center;
background: #4cd4a6;
color: #17141a;
cursor: pointer;
}
.pcast__play svg {
transform: translateX(1px);
}
.pcast__play:hover {
filter: brightness(1.08);
}
.pcast__body {
flex: 1 1 auto;
min-width: 0;
}
.pcast__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pcast__meta {
margin: 4px 0 8px;
font-size: 11px;
color: #9491a3;
}
.pcast__wave {
display: flex;
align-items: flex-end;
gap: 4px;
height: 20px;
}
.pcast__wave span {
display: block;
width: 3px;
height: 6px;
border-radius: 4px;
background: #4cd4a6;
transform-origin: bottom;
}
.pcast__wave span:nth-child(1) {
animation-delay: 80ms;
}
.pcast__wave span:nth-child(2) {
animation-delay: 160ms;
}
.pcast__wave span:nth-child(3) {
animation-delay: 240ms;
}
.pcast__wave span:nth-child(4) {
animation-delay: 320ms;
}
.pcast__wave span:nth-child(5) {
animation-delay: 400ms;
}
.pcast__wave span:nth-child(6) {
animation-delay: 480ms;
}
.pcast__wave span:nth-child(7) {
animation-delay: 560ms;
}
.pcast__wave span:nth-child(8) {
animation-delay: 640ms;
}
.stage.is-demo .pcast__wave span {
animation: pcast-bar 900ms ease-out infinite alternate;
}
.pcast__wave.is-paused span {
animation-play-state: paused;
height: 6px;
}
@keyframes pcast-bar {
from {
transform: scaleY(0.3);
}
to {
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .pcast__wave span {
animation: none;
transform: scaleY(0.6);
}
}// 실제 동작: 再生 버튼을 누르면 아이콘이 바뀌고 파형 애니메이션이 실제로 멈췄다 다시 움직인다.
var btn = document.querySelector('.pcast__play');
var wave = document.querySelector('.pcast__wave');
var play = document.querySelector('.ic-play');
var pause = document.querySelector('.ic-pause');
var playing = false;
btn.addEventListener('click', function () {
playing = !playing;
play.hidden = playing;
pause.hidden = !playing;
wave.classList.toggle('is-paused', !playing);
btn.setAttribute('aria-label', playing ? '一時停止' : '再生');
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PodcastPlayer.module.scss";
export type PodcastPlayerProps = {
duration?: number;
easing?: string;
color?: string;
title?: string;
};
export default function PodcastPlayer({ duration = 300, easing = "ease-out", color = "#4cd4a6", title = "EP.09 CSS 만으로 배지 만들기" }: PodcastPlayerProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [playing, setPlaying] = useState(false);
return (
<div className={styles.player} style={vars}>
<button className={styles.play} type="button" aria-label={playing ? "일시정지" : "재생"} onClick={() => setPlaying((v) => !v)}>
{playing ? (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" />
<path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z" />
</g></svg>
) : (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" />
<path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z" />
</g></svg>
)}
</button>
<div className={styles.body}>
<p className={styles.title}>{title}</p>
<p className={styles.meta}>Godrich Story FM · 24:10</p>
<div className={`${styles.wave} ${!playing ? styles.paused : ""}`} aria-hidden="true">
{Array.from({ length: 8 }).map((_, i) => (
<span key={i} style={{ animationDelay: `${i * 80}ms` }} />
))}
</div>
</div>
</div>
);
}06 · ポッドキャストプレーヤー埋め込みウィジェット — 計測 累積面積 0.083% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
ブログ記事内のポッドキャスト回カードや、サイドバーの最新エピソードウィジェットで使います。
.wave span { animation: bar #{$duration * 3} ease-out infinite alternate; }
.wave.is-paused span { animation-play-state: paused; }
@keyframes bar {
from { transform: scaleY(.3); }
to { transform: scaleY(1); }
}
再生ボタンを押すと波形が実際に止まり、また動き出します — animation-play-state一行で再生状態とアニメーションを結び付けています。
07GitHubリポジトリ統計カード
何が見えるか
星アイコンが押してほしいと言うように左右に小さく揺れます。

<main class="stage is-demo">
<div class="ghcard">
<div class="ghcard__head">
<span class="ghcard__repo">godrichstory / demo-widgets</span>
</div>
<p class="ghcard__desc">埋め込みウィジェット9種 — vanilla + React、MIT</p>
<div class="ghcard__stats">
<span class="ghcard__lang"><i class="ghcard__dot" aria-hidden="true"></i>SCSS</span>
<button class="ghcard__star" type="button" aria-pressed="false">
<svg class="ic-star" viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z"/></g></svg>
<span class="ghcard__star-count">482</span>
</button>
<span class="ghcard__fork">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M88 64a24 24 0 1 1-24-24a24 24 0 0 1 24 24m104-24a24 24 0 1 0 24 24a24 24 0 0 0-24-24" opacity=".2"/><path d="M224 64a32 32 0 1 0-40 31v17a8 8 0 0 1-8 8H80a8 8 0 0 1-8-8V95a32 32 0 1 0-16 0v17a24 24 0 0 0 24 24h40v25a32 32 0 1 0 16 0v-25h40a24 24 0 0 0 24-24V95a32.06 32.06 0 0 0 24-31M48 64a16 16 0 1 1 16 16a16 16 0 0 1-16-16m96 128a16 16 0 1 1-16-16a16 16 0 0 1 16 16m48-112a16 16 0 1 1 16-16a16 16 0 0 1-16 16"/></g></svg>
56
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 별 아이콘 안내 펄스
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.ghcard {
width: 264px;
padding: $sp-4;
border-radius: $r-card;
border: 1px solid #e7e5ea;
background: #fff;
box-shadow: $shadow-press;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ghcard__repo { font-size: 13px; font-weight: 700; color: $color; }
.ghcard__desc { margin: $sp-2 0 $sp-3; font-size: 12px; color: #6b6875; line-height: 1.5; }
.ghcard__stats { display: flex; align-items: center; gap: $sp-4; font-size: 12px; color: #6b6875; }
.ghcard__lang { display: flex; align-items: center; gap: $sp-1; }
.ghcard__dot { width: 10px; height: 10px; border-radius: 50%; background: #caabd6; display: inline-block; }
.ghcard__star, .ghcard__fork { display: flex; align-items: center; gap: $sp-1; }
.ghcard__star { border: 0; background: none; padding: 0; cursor: pointer; color: #6b6875; }
.ghcard__star:hover, .ghcard__star.is-active { color: #baa272; }
.ghcard__star.is-active .ic-star { fill: #baa272; }
// 자동 재생 루프 — 별 아이콘이 눌러보라는 듯 전 구간 계속 흔들린다 (절반 넘게 멈춰 있으면 프레임 판정에서 떨어진다)
.stage.is-demo .ghcard__star .ic-star { animation: ghcard-wiggle $duration $easing infinite; transform-origin: 50% 90%; }
@keyframes ghcard-wiggle {
0% { transform: rotate(0) scale(1); }
20% { transform: rotate(-16deg) scale(1.15); }
40% { transform: rotate(12deg) scale(1.05); }
60% { transform: rotate(-10deg) scale(1.15); }
80% { transform: rotate(8deg) scale(1.05); }
100% { transform: rotate(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ghcard__star .ic-star { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ghcard {
width: 264px;
padding: 16px;
border-radius: 16px;
border: 1px solid #e7e5ea;
background: #fff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ghcard__repo {
font-size: 13px;
font-weight: 700;
color: #2f6df6;
}
.ghcard__desc {
margin: 8px 0 12px;
font-size: 12px;
color: #6b6875;
line-height: 1.5;
}
.ghcard__stats {
display: flex;
align-items: center;
gap: 16px;
font-size: 12px;
color: #6b6875;
}
.ghcard__lang {
display: flex;
align-items: center;
gap: 4px;
}
.ghcard__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #caabd6;
display: inline-block;
}
.ghcard__star, .ghcard__fork {
display: flex;
align-items: center;
gap: 4px;
}
.ghcard__star {
border: 0;
background: none;
padding: 0;
cursor: pointer;
color: #6b6875;
}
.ghcard__star:hover, .ghcard__star.is-active {
color: #baa272;
}
.ghcard__star.is-active .ic-star {
fill: #baa272;
}
.stage.is-demo .ghcard__star .ic-star {
animation: ghcard-wiggle 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
transform-origin: 50% 90%;
}
@keyframes ghcard-wiggle {
0% {
transform: rotate(0) scale(1);
}
20% {
transform: rotate(-16deg) scale(1.15);
}
40% {
transform: rotate(12deg) scale(1.05);
}
60% {
transform: rotate(-10deg) scale(1.15);
}
80% {
transform: rotate(8deg) scale(1.05);
}
100% {
transform: rotate(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ghcard__star .ic-star {
animation: none;
}
}// 실제 동작: Star 버튼을 누르면 카운트가 오르내리고 별이 채워진 상태로 바뀐다.
var btn = document.querySelector('.ghcard__star');
var count = document.querySelector('.ghcard__star-count');
btn.addEventListener('click', function () {
var pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
btn.classList.toggle('is-active', !pressed);
count.textContent = parseInt(count.textContent, 10) + (pressed ? -1 : 1);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GithubRepoCard.module.scss";
export type GithubRepoCardProps = {
duration?: number;
easing?: string;
color?: string;
repo?: string;
};
export default function GithubRepoCard({ duration = 2000, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6", repo = "godrichstory / demo-widgets" }: GithubRepoCardProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [starred, setStarred] = useState(false);
const [stars, setStars] = useState(482);
return (
<div className={styles.card} style={vars}>
<div><span className={styles.repo}>{repo}</span></div>
<p className={styles.desc}>임베드 위젯 9종 — vanilla + React, MIT</p>
<div className={styles.stats}>
<span className={styles.lang}><i className={styles.dot} aria-hidden="true" />SCSS</span>
<button
className={`${styles.star} ${starred ? styles.active : ""}`}
type="button" aria-pressed={starred}
onClick={() => { setStarred((v) => !v); setStars((n) => n + (starred ? -1 : 1)); }}
>
<svg className={styles.icStar} viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" />
<path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59Z" />
</g></svg>
<span>{stars}</span>
</button>
<span className={styles.fork}>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M88 64a24 24 0 1 1-24-24a24 24 0 0 1 24 24m104-24a24 24 0 1 0 24 24a24 24 0 0 0-24-24" />
<path d="M224 64a32 32 0 1 0-40 31v17a8 8 0 0 1-8 8H80a8 8 0 0 1-8-8V95a32 32 0 1 0-16 0v17a24 24 0 0 0 24 24h40v25a32 32 0 1 0 16 0v-25h40a24 24 0 0 0 24-24V95a32.06 32.06 0 0 0 24-31M48 64a16 16 0 1 1 16 16a16 16 0 0 1-16-16m96 128a16 16 0 1 1-16-16a16 16 0 0 1 16 16m48-112a16 16 0 1 1 16-16a16 16 0 0 1-16 16" />
</g></svg>
56
</span>
</div>
</div>
);
}07 · GitHubリポジトリ統計カード — 計測 累積面積 0.198% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
本物のGitHub APIは呼ばないので、OSSプロジェクト紹介記事のリポジトリカードや、ポートフォリオのコードリンクに使えます。
.icStar { animation: wiggle $duration $easing infinite; transform-origin: 50% 90%; }
@keyframes wiggle {
0% { transform: rotate(0) scale(1); }
20% { transform: rotate(-16deg) scale(1.15); }
40% { transform: rotate(12deg) scale(1.05); }
60% { transform: rotate(-10deg) scale(1.15); }
100% { transform: rotate(0) scale(1); }
}
Starを実際に押すとカウントが増減し、星の色が塗りつぶされます。
08ニュースレター購読埋め込みウィジェット
画面で起きること
封筒アイコンが浮かんでは沈むことを繰り返します。

<main class="stage is-demo">
<form class="ncard" novalidate>
<span class="ncard__icon" aria-hidden="true">
<svg class="ic-mail" viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="m224 56l-96 88l-96-88Z" opacity=".2"/><path d="M224 48H32a8 8 0 0 0-8 8v136a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a8 8 0 0 0-8-8m-20.57 16L128 133.15L52.57 64ZM216 192H40V74.19l82.59 75.71a8 8 0 0 0 10.82 0L216 74.19z"/></g></svg>
<svg class="ic-sent" viewBox="0 0 256 256" width="22" height="22" hidden><g fill="currentColor"><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2"/><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66"/></g></svg>
</span>
<p class="ncard__title">新しいデモが出るたびにメールで</p>
<div class="ncard__row">
<label class="ncard__label" for="ncard-email-234-08">メールアドレス</label>
<input class="ncard__input" id="ncard-email-234-08" type="email" placeholder="[email protected]" required>
<button class="ncard__btn" type="submit">登録</button>
</div>
<p class="ncard__msg" role="status"></p>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 편지 아이콘이 떠올랐다 가라앉는 한 주기
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-orange);
.ncard {
width: 272px;
box-sizing: border-box;
padding: $sp-4;
border-radius: $r-card;
text-align: center;
background: linear-gradient(160deg, #fff, $subject-cream 120%);
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ncard__icon {
display: grid; place-items: center;
width: 44px; height: 44px;
margin: 0 auto $sp-2;
border-radius: 50%;
background: rgba($color, .16);
color: $color;
}
.ncard__title { margin: 0 0 $sp-3; font-size: 13px; font-weight: 700; color: #3a2f1f; }
.ncard__row { display: flex; flex-wrap: wrap; gap: $sp-2; justify-content: center; }
.ncard__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.ncard__input {
flex: 1 1 140px;
min-width: 0;
border: 1px solid #e7ddc8;
border-radius: $r-pill;
padding: $sp-2 $sp-3;
font-size: 12px;
font-family: inherit;
}
.ncard__input:focus { outline: 2px solid $color; outline-offset: 2px; }
.ncard__input:disabled { background: #f4efe4; color: #9c8f74; }
.ncard__btn {
flex: 0 0 auto;
border: 0;
border-radius: $r-pill;
background: $color;
color: $subject-ink; // 파스텔 면 위 흰 글자는 2.20:1 — 잉크로 8.28:1
padding: $sp-2 $sp-4;
font-size: 12px; font-weight: 700;
cursor: pointer;
}
.ncard__btn:hover { filter: brightness(1.06); }
.ncard__btn:disabled { cursor: default; filter: none; opacity: .8; }
.ncard__msg { min-height: 16px; margin: $sp-2 0 0; font-size: 11px; }
.ncard__msg.is-error { color: $error; }
.ncard__msg.is-ok { color: #50a26e; }
// 자동 재생 루프 — 편지가 살짝 떠올랐다 가라앉는다
.stage.is-demo .ncard__icon { animation: ncard-float $duration $easing infinite; }
@keyframes ncard-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ncard__icon { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ncard {
width: 272px;
box-sizing: border-box;
padding: 16px;
border-radius: 16px;
text-align: center;
background: linear-gradient(160deg, #fff, #fff7e6 120%);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ncard__icon {
display: grid;
place-items: center;
width: 44px;
height: 44px;
margin: 0 auto 8px;
border-radius: 50%;
background: rgba(185, 160, 255, 0.16);
color: #b9a0ff;
}
.ncard__title {
margin: 0 0 12px;
font-size: 13px;
font-weight: 700;
color: #3a2f1f;
}
.ncard__row {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.ncard__label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.ncard__input {
flex: 1 1 140px;
min-width: 0;
border: 1px solid #e7ddc8;
border-radius: 999px;
padding: 8px 12px;
font-size: 12px;
font-family: inherit;
}
.ncard__input:focus {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.ncard__input:disabled {
background: #f4efe4;
color: #9c8f74;
}
.ncard__btn {
flex: 0 0 auto;
border: 0;
border-radius: 999px;
background: #b9a0ff;
color: #17141a;
padding: 8px 16px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.ncard__btn:hover {
filter: brightness(1.06);
}
.ncard__btn:disabled {
cursor: default;
filter: none;
opacity: 0.8;
}
.ncard__msg {
min-height: 16px;
margin: 8px 0 0;
font-size: 11px;
}
.ncard__msg.is-error {
color: #c2361a;
}
.ncard__msg.is-ok {
color: #50a26e;
}
.stage.is-demo .ncard__icon {
animation: ncard-float 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ncard-float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ncard__icon {
animation: none;
}
}// 실제 동작: メールアドレス 형식을 검사해 통과하면 完了 상태로 바뀐다 (실제 전송은 없는 데모).
var form = document.querySelector('.ncard');
var input = document.querySelector('.ncard__input');
var btn = document.querySelector('.ncard__btn');
var msg = document.querySelector('.ncard__msg');
var mail = document.querySelector('.ic-mail');
var sent = document.querySelector('.ic-sent');
form.addEventListener('submit', function (e) {
e.preventDefault();
var ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.value);
if (!ok) { msg.textContent = 'メールアドレスの形式をご確認ください'; msg.className = 'ncard__msg is-error'; return; }
input.disabled = true;
btn.disabled = true;
btn.textContent = '完了';
mail.hidden = true;
sent.hidden = false;
msg.textContent = '登録完了 — 確認メールをお送りしました';
msg.className = 'ncard__msg is-ok';
});import { useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./NewsletterSignup.module.scss";
export type NewsletterSignupProps = {
duration?: number;
easing?: string;
color?: string;
};
export default function NewsletterSignup({ duration = 2000, easing = "cubic-bezier(.2,.8,.2,1)", color = "#b9a0ff" }: NewsletterSignupProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "error" | "ok">("idle");
function onSubmit(e: { preventDefault: () => void }) {
e.preventDefault();
const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
setStatus(ok ? "ok" : "error");
}
return (
<form className={styles.card} style={vars} onSubmit={onSubmit} noValidate>
<span className={styles.icon} aria-hidden="true">
{status === "ok" ? (
<svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor">
<path opacity=".2" d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" />
<path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66" />
</g></svg>
) : (
<svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor">
<path opacity=".2" d="m224 56l-96 88l-96-88Z" />
<path d="M224 48H32a8 8 0 0 0-8 8v136a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a8 8 0 0 0-8-8m-20.57 16L128 133.15L52.57 64ZM216 192H40V74.19l82.59 75.71a8 8 0 0 0 10.82 0L216 74.19z" />
</g></svg>
)}
</span>
<p className={styles.title}>새 데모가 나올 때마다 메일로</p>
<div className={styles.row}>
<label className={styles.srOnly} htmlFor="ncard-email-react">이메일</label>
<input
className={styles.input} id="ncard-email-react" type="email" placeholder="[email protected]"
value={email} disabled={status === "ok"} onChange={(e: ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)} required
/>
<button className={styles.btn} type="submit" disabled={status === "ok"}>{status === "ok" ? "완료" : "구독"}</button>
</div>
<p className={`${styles.msg} ${status === "error" ? styles.msgError : status === "ok" ? styles.msgOk : ""}`} role="status">
{status === "error" ? "이메일 형식을 확인해 주세요" : status === "ok" ? "구독 완료 — 확인 메일을 보냈어요" : ""}
</p>
</form>
);
}08 · ニュースレター購読埋め込みウィジェット — 計測 累積面積 0.66% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
記事下部の購読誘導カードや、サイドバーのニュースレター登録フォームで使います。
.icon { animation: float $duration $easing infinite; }
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@media (prefers-reduced-motion: reduce) {
.icon { animation: none; }
}
送信時に正規表現でメール形式を実際に検証し、通過すると紙飛行機のアイコンに変わり、完了メッセージが出ます。
09ライブチャットバブルランチャー
どんな動きか
ランチャー周りの白いリングがレーダーのように広がり、バッジの数字が呼吸するように大きくなったり小さくなったりします。

<main class="stage is-demo">
<div class="lchat">
<div class="lchat__panel" hidden>
<header class="lchat__head">
<span>リアルタイム相談</span>
<button class="lchat__close" type="button" aria-label="閉じる">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>
</button>
</header>
<p class="lchat__msg">こんにちは!何かお手伝いできますか?</p>
<div class="lchat__input-row">
<input class="lchat__input" type="text" placeholder="メッセージを入力してください" aria-label="メッセージ">
<button class="lchat__send" type="button">送信</button>
</div>
</div>
<button class="lchat__launcher" type="button" aria-label="チャットを開く" aria-expanded="false">
<span class="lchat__ring" aria-hidden="true"></span>
<svg viewBox="0 0 256 256" width="24" height="24" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-4-1.08a7.9 7.9 0 0 0-2.53.42L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216m12-88a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-44 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12m88 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg>
<span class="lchat__badge">2</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 런처 둘레 링이 신호를 보내는 한 주기
$easing: $ease-out;
$color: $subject-lilac;
@include stage($stage-orange);
.lchat {
position: relative;
width: 260px;
height: 150px;
}
.lchat__launcher {
position: absolute;
right: 0; bottom: 0;
width: 60px; height: 60px;
border-radius: 50%;
border: 0;
display: grid; place-items: center;
background: linear-gradient(150deg, $subject-pink, $color);
color: $subject-ink; // 파스텔 면 위 흰 글자는 2.20:1 — 잉크로 9.43:1
cursor: pointer;
box-shadow: $shadow-raised;
}
.lchat__ring {
position: absolute;
inset: -8px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, .6);
opacity: 0;
transform: scale(.85);
}
.lchat__badge {
position: absolute;
top: -4px; right: -4px;
min-width: 20px; height: 20px;
border-radius: $r-pill;
background: $subject-ink; // 흰 배경 + 라일락 글자는 2.20:1 — 먹 배경 + 크림 글자로
color: $subject-cream;
display: grid; place-items: center;
font-size: 11px; font-weight: 800;
box-shadow: 0 0 0 2px $stage-orange;
}
.lchat__panel {
position: absolute;
right: 0; bottom: 72px;
width: 224px;
padding: $sp-3;
border-radius: $r-card;
background: #fff;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lchat__head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 700; color: $stage-ink; }
.lchat__close { border: 0; background: none; padding: 0; color: #6b6875; cursor: pointer; }
.lchat__msg { margin: $sp-2 0; padding: $sp-2; border-radius: $r-sm; background: #f4f0fa; font-size: 12px; color: #3a2f1f; }
.lchat__input-row { display: flex; gap: $sp-1; }
.lchat__input { flex: 1 1 auto; min-width: 0; border: 1px solid #e7e5ea; border-radius: $r-sm; padding: $sp-1 $sp-2; font-size: 12px; font-family: inherit; }
.lchat__send { flex: 0 0 auto; border: 0; border-radius: $r-sm; background: $color; color: #fff; padding: $sp-1 $sp-2; font-size: 12px; cursor: pointer; }
// 자동 재생 루프 — 링이 레이더처럼 퍼지고 배지가 숨쉰다
.stage.is-demo .lchat__ring { animation: lchat-ping $duration $easing infinite; }
.stage.is-demo .lchat__badge { animation: lchat-bounce $duration $ease-pop infinite; }
@keyframes lchat-ping {
0% { opacity: .5; transform: scale(.85); }
70% { opacity: 0; transform: scale(1.25); }
100% { opacity: 0; transform: scale(1.25); }
}
@keyframes lchat-bounce {
0%, 40%, 100% { transform: scale(1); }
20% { transform: scale(1.25); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .lchat__ring { animation: none; opacity: 0; }
.stage.is-demo .lchat__badge { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lchat {
position: relative;
width: 260px;
height: 150px;
}
.lchat__launcher {
position: absolute;
right: 0;
bottom: 0;
width: 60px;
height: 60px;
border-radius: 50%;
border: 0;
display: grid;
place-items: center;
background: linear-gradient(150deg, #ff9ebb, #b9a0ff);
color: #17141a;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.lchat__ring {
position: absolute;
inset: -8px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.6);
opacity: 0;
transform: scale(0.85);
}
.lchat__badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 20px;
height: 20px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
display: grid;
place-items: center;
font-size: 11px;
font-weight: 800;
box-shadow: 0 0 0 2px #ff4d1f;
}
.lchat__panel {
position: absolute;
right: 0;
bottom: 72px;
width: 224px;
padding: 12px;
border-radius: 16px;
background: #fff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lchat__head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 700;
color: #17141a;
}
.lchat__close {
border: 0;
background: none;
padding: 0;
color: #6b6875;
cursor: pointer;
}
.lchat__msg {
margin: 8px 0;
padding: 8px;
border-radius: 8px;
background: #f4f0fa;
font-size: 12px;
color: #3a2f1f;
}
.lchat__input-row {
display: flex;
gap: 4px;
}
.lchat__input {
flex: 1 1 auto;
min-width: 0;
border: 1px solid #e7e5ea;
border-radius: 8px;
padding: 4px 8px;
font-size: 12px;
font-family: inherit;
}
.lchat__send {
flex: 0 0 auto;
border: 0;
border-radius: 8px;
background: #b9a0ff;
color: #fff;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
}
.stage.is-demo .lchat__ring {
animation: lchat-ping 2s ease-out infinite;
}
.stage.is-demo .lchat__badge {
animation: lchat-bounce 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes lchat-ping {
0% {
opacity: 0.5;
transform: scale(0.85);
}
70% {
opacity: 0;
transform: scale(1.25);
}
100% {
opacity: 0;
transform: scale(1.25);
}
}
@keyframes lchat-bounce {
0%, 40%, 100% {
transform: scale(1);
}
20% {
transform: scale(1.25);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .lchat__ring {
animation: none;
opacity: 0;
}
.stage.is-demo .lchat__badge {
animation: none;
}
}// 실제 동작: 런처를 누르면 채팅 패널이 실제로 열리고 닫히며, 열릴 때 입력창에 포커스가 간다(스크롤은 강제하지 않는다).
var launcher = document.querySelector('.lchat__launcher');
var panel = document.querySelector('.lchat__panel');
var closeBtn = document.querySelector('.lchat__close');
var input = document.querySelector('.lchat__input');
var badge = document.querySelector('.lchat__badge');
function open() {
panel.hidden = false;
launcher.setAttribute('aria-expanded', 'true');
badge.hidden = true;
input.focus({ preventScroll: true });
}
function close() {
panel.hidden = true;
launcher.setAttribute('aria-expanded', 'false');
}
launcher.addEventListener('click', function () {
if (panel.hidden) open(); else close();
});
closeBtn.addEventListener('click', close);import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LiveChatBubble.module.scss";
export type LiveChatBubbleProps = {
duration?: number;
easing?: string;
color?: string;
unread?: number;
};
export default function LiveChatBubble({ duration = 2000, easing = "ease-out", color = "#b9a0ff", unread = 2 }: LiveChatBubbleProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const [open, setOpen] = useState(false);
const [seen, setSeen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
function toggle() {
const next = !open;
setOpen(next);
if (next) {
setSeen(true);
requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
}
}
return (
<div className={styles.wrap} style={vars}>
{open && (
<div className={styles.panel}>
<header className={styles.head}>
<span>실시간 문의</span>
<button className={styles.close} type="button" aria-label="닫기" onClick={() => setOpen(false)}>
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" />
<path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g></svg>
</button>
</header>
<p className={styles.msg}>안녕하세요! 무엇을 도와드릴까요?</p>
<div className={styles.inputRow}>
<input ref={inputRef} className={styles.input} type="text" placeholder="메시지를 입력하세요" aria-label="메시지" />
<button className={styles.send} type="button">전송</button>
</div>
</div>
)}
<button className={styles.launcher} type="button" aria-label="채팅 열기" aria-expanded={open} onClick={toggle}>
<span className={styles.ring} aria-hidden="true" />
<svg viewBox="0 0 256 256" width="24" height="24" aria-hidden="true"><g fill="currentColor">
<path opacity=".2" d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" />
<path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-4-1.08a7.9 7.9 0 0 0-2.53.42L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216m12-88a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-44 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12m88 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12" />
</g></svg>
{!seen && <span className={styles.badge}>{unread}</span>}
</button>
</div>
);
}09 · ライブチャットバブルランチャー — 計測 累積面積 2.226% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
サイト右下固定のお問い合わせボタンや、決済ページのリアルタイム相談窓口で使います。
.ring { animation: ping $duration $easing infinite; }
.badge { animation: bounce $duration $ease-pop infinite; }
@keyframes ping {
0% { opacity: .5; transform: scale(.85); }
70%, 100% { opacity: 0; transform: scale(1.25); }
}
実際に押すとチャットパネルが開き、.focus({ preventScroll: true })のおかげで入力欄にフォーカスが移ってもページは強制スクロールされません。
落とし穴 — 320pxはどこで画面を超えるか
X投稿カード(03)と口コミカード(04)は最初、カード幅280pxで余白も広めに取っていました。블로그설정.jsonのスマホ幅320pxでPlaywright実測すると、document.body.scrollHeightが200pxの予算を14px・2pxそれぞれ超えていました — document.documentElementだけを測るとこの超過を見逃します(ステージCSSがoverflow: hiddenを掛けてスクロールバー自体が出ないためです)。カード幅を256pxに縮め、余白をトークン1段ずつ($sp-4→$sp-3、$sp-3→$sp-2)下げると、ちょうど200pxに収まりました。地図カード(05)は地図エリアの高さを120pxから88pxに縮めた分がより大きく効きました。border-radiusは$r-*トークンのみ、gap・padding・marginのpx値は4pxの倍数のみと決められているため、再生アイコンを1〜2pxずらすmargin-leftもすべてtransform: translateX()に置き換える必要がありました。埋め込みカードの中に数値を描く必要があるときは、css グラフ をSVGで描くの座標計算をそのまま持ってこられます。9つのzipを開くときのパスワードはxc2tmqdqで、小文字と数字だけなので表示のとおりそのまま貼り付けられます。
アクセシビリティ
9つ全部がprefers-reduced-motion: reduceでループアニメーションを止め、静止した形に落ち着きます — 再生リング(02)とチャットバブルのリング(09)はopacity: 0に固定し、リングが止まったまま浮いて残ることはありません。ボタンにはaria-label・aria-pressed・aria-expandedを付け、いいねが押されたか、パネルが開いたかをスクリーンリーダーが読み取れます。チャットバブルの入力欄フォーカスは常に{ preventScroll: true }付きで、画面が突然動くことはありません。この設定をどこで切り替えるかはMDNのprefers-reduced-motionドキュメントにまとまっています。
FAQ
このウィジェット デザインのカードで本物のYouTube・X・地図が開きますか?
いいえ。9つ全部が本物のAPI・iframeを呼ばないCSS・バニラJSのモックです。動画カード(02)は押すと進行バーが埋まるだけで、X風カード(03)のいいね・リポスト数もこのカードの中だけで動きます。本物の埋め込みが必要なときは、各サービスの公式embedコードを同じ場所に入れ、このCSSはロード前のプレースホルダーとして使えます。ほかの埋め込みパターンはサイト内検索で「埋め込み」で探せます。ウィジェットが最終的に収まるページ下部の構成はフッター デザインでまとめて扱っています。
色や文言を自社ブランドに合わせて変えられますか?
変えられます。ほとんどのデモはSCSS先頭の$duration・$easing・$colorの3変数を変えるだけで、アニメーションの色がまとめて変わります。テキスト(会社名・住所・口コミ文言)はindex.html内の文字列を直接書き換えれば済み、React版は各コンポーネントのprops(title・address・quoteなど)で同じことができます。
320px超えは自分でどう確認しますか?
開発者ツールでビューポートを320pxに狭め、コンソールでdocument.documentElement.scrollWidthとdocument.body.scrollWidthをそれぞれ調べてwindow.innerWidthと比べます。documentElementだけを測ると、この記事の落とし穴のようにoverflow: hiddenの裏に隠れた超過を見逃します。このサイトがデモをどう作り検証しているかは紹介ページにまとめています。









