プロフィールカード デザイン 9選 — コピペOKのスタイル集
プロフィールカード デザインとは、アバター・名前・肩書き・数値・フォローボタンという決まった中身に、どんな質感を着せるかを決める作業です。9つは内側の構造がすべて同じで、表面と動きだけが違います。
01 profile-card-glass-frost
02 profile-card-neo-brutal-offset
03 profile-card-clay-puffed
04 profile-card-minimal-line
05 profile-card-gradient-border-spin
06 profile-card-dark-glow-ring
07 profile-card-flip-contact
08 profile-card-stat-countup
09 profile-card-team-grid-expand
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 グラスモーフィズム
- 02 ネオブルータリズム オフセット
- 03 クレイモーフィズム
- 04 ミニマルライン
- 05 回転グラデーション枠
- 06 ダークグローリング
- 07 3Dフリップ連絡先
- 08 ソーシャル数値カウントアップ
- 09 チームグリッド展開
前半の6つはカード1枚の表面だけを変えます。中身の項目も文言もボタンの動きも共通で、違うのはスタイルと、質感のために足した数個の要素だけです。並び順は光をどれだけ扱うかの順です。後ろにあるものをぼかして映すガラス(01)から始まり、光の代わりに影を図形として使うネオブルータリズム(02)、内と外の影を重ねて厚みを作る粘土(03)、影をすべて捨てて線だけ残したミニマル(04)、枠だけが光るグラデーション(05)、暗闇でにじむネオン(06)へと進みます。後半の3つは表面ではなく構造をひとつずつ足します — 裏面(07)、時間(08)、隣(09)です。コピペできるプロフィールカード テンプレートですが、9つとも、フォローボタンは実際に aria-pressed を切り替えて文字とアイコンを差し替えます。顔をカードではなくセクション単位で上げるなら信頼を作るLPパーツが担い、カードに価格が入る瞬間は料金表 デザイン 実装が引き継ぎます。
01グラスモーフィズム
「css プロフィールカード」で何が見えるか
カードの後ろで色のにじみが2つゆっくり流れ、半透明のカードがその色をぼかして映します。

<main class="stage">
<div class="gf is-demo">
<div class="gf__blob gf__blob--a" aria-hidden="true"></div>
<div class="gf__blob gf__blob--b" aria-hidden="true"></div>
<article class="gf__card">
<div class="gf__head">
<div class="gf__avatar">SR</div>
<div class="gf__who">
<p class="gf__name">イ・ソリン</p>
<p class="gf__role">プロダクトデザイナー</p>
</div>
</div>
<div class="gf__stats">
<div class="gf__stat"><strong class="gf__num">2,431</strong><span class="gf__lab">フォロワー</span></div>
<div class="gf__stat"><strong class="gf__num">128</strong><span class="gf__lab">投稿</span></div>
</div>
<button class="gf__btn" type="button" id="gf-btn" aria-pressed="false">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" hidden><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span id="gf-label">フォロー</span>
</button>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
// 글래스모피즘 — 반투명 카드가 뒤에서 떠다니는 색 얼룩 두 개를 흐리게 비춘다.
.gf {
position: relative;
width: 300px;
height: 192px;
overflow: hidden; // 얼룩이 카드 바깥으로 새지 않게 (고정 크기 + clip)
border-radius: $r-card;
user-select: none;
-webkit-user-select: none;
}
.gf__blob { position: absolute; border-radius: 50%; pointer-events: none; }
.gf__blob--a {
top: -24px; left: -30px;
width: 140px; height: 140px;
background: radial-gradient(circle at 38% 38%, $subject-pink, rgba($subject-pink, 0) 72%);
}
.gf__blob--b {
top: 50px; left: 160px;
width: 150px; height: 150px;
background: radial-gradient(circle at 62% 62%, $subject-lilac, rgba($subject-lilac, 0) 72%);
}
.gf__card {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-3;
padding: 0 $sp-4;
background: rgba(255, 255, 255, .30);
border: 1px solid rgba(255, 255, 255, .62);
border-radius: $r-card;
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
color: $subject-ink;
}
.gf__head { display: flex; align-items: center; gap: $sp-3; }
.gf__avatar {
display: flex; align-items: center; justify-content: center;
width: 56px; height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, $subject-cream, $subject-pink);
border: 1px solid rgba(255, 255, 255, .55);
color: $subject-ink;
font-size: 18px; line-height: 22px; font-weight: 800;
}
.gf__who { display: flex; flex-direction: column; gap: $sp-1; }
.gf__name { margin: 0; font-size: 20px; line-height: 24px; font-weight: 800; color: $subject-ink; }
.gf__role { margin: 0; font-size: 12px; line-height: 16px; font-weight: 600; color: rgba(23, 20, 26, .74); }
.gf__stats { display: flex; align-items: center; gap: $sp-4; }
.gf__stat { display: flex; flex-direction: column; align-items: center; gap: $sp-1; }
.gf__stat + .gf__stat { border-left: 1px solid rgba(23, 20, 26, .22); padding-left: $sp-4; }
.gf__num { font-size: 17px; line-height: 20px; font-weight: 800; color: $subject-ink; font-variant-numeric: tabular-nums; }
.gf__lab { font-size: 11px; line-height: 13px; font-weight: 600; color: rgba(23, 20, 26, .74); }
.gf__btn {
display: inline-flex; align-items: center; justify-content: center;
gap: $sp-1;
height: 36px;
padding: 0 $sp-4;
background: rgba(255, 255, 255, .58);
border: 1px solid rgba(255, 255, 255, .82);
border-radius: $r-pill;
color: $subject-ink;
font-size: 13px; line-height: 16px; font-weight: 700;
cursor: pointer;
transition: background $duration $easing, color $duration $easing;
}
.gf__btn svg { display: block; }
.gf__btn svg[hidden] { display: none; }
.gf__btn:hover { background: rgba(255, 255, 255, .72); }
.gf__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.gf__btn[aria-pressed="true"] { background: $subject-ink; border-color: rgba(23, 20, 26, .55); color: $subject-cream; }
// ── 자동 루프: 얼룩 두 개가 서로 다른 경로로 카드 뒤를 천천히 지난다. 끝 프레임 = 첫 프레임 ──
.gf.is-demo .gf__blob--a { animation: gf-drift-a $dur-loop linear infinite; }
.gf.is-demo .gf__blob--b { animation: gf-drift-b $dur-loop linear infinite; }
@keyframes gf-drift-a {
0%, 100% { transform: translate(0, 0); }
36% { transform: translate(84px, 46px); }
70% { transform: translate(148px, 8px); }
}
@keyframes gf-drift-b {
0%, 100% { transform: translate(0, 0); }
40% { transform: translate(-64px, -38px); }
76% { transform: translate(-132px, -10px); }
}
@media (max-width: 340px) {
.gf { width: 272px; height: 146px; }
.gf__card { gap: $sp-1; padding: 0 $sp-3; }
.gf__head { gap: $sp-2; }
.gf__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 18px; }
.gf__who { gap: 0; }
.gf__name { font-size: 14px; line-height: 18px; }
.gf__role { font-size: 11px; line-height: 13px; }
.gf__stats { gap: $sp-3; }
.gf__stat { gap: 0; }
.gf__stat + .gf__stat { padding-left: $sp-3; }
.gf__num { font-size: 13px; line-height: 16px; }
.gf__lab { font-size: 11px; line-height: 13px; }
.gf__btn { height: 32px; padding: 0 $sp-3; font-size: 12px; line-height: 14px; }
.gf__blob--a { width: 104px; height: 104px; }
.gf__blob--b { width: 116px; height: 116px; top: 32px; left: 148px; }
}
@media (prefers-reduced-motion: reduce) {
.gf.is-demo .gf__blob--a,
.gf.is-demo .gf__blob--b { animation: none !important; }
.gf__btn { transition: 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);
}
.gf {
position: relative;
width: 300px;
height: 192px;
overflow: hidden;
border-radius: 16px;
user-select: none;
-webkit-user-select: none;
}
.gf__blob {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.gf__blob--a {
top: -24px;
left: -30px;
width: 140px;
height: 140px;
background: radial-gradient(circle at 38% 38%, #ff9ebb, rgba(255, 158, 187, 0) 72%);
}
.gf__blob--b {
top: 50px;
left: 160px;
width: 150px;
height: 150px;
background: radial-gradient(circle at 62% 62%, #b9a0ff, rgba(185, 160, 255, 0) 72%);
}
.gf__card {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 0 16px;
background: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.62);
border-radius: 16px;
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
color: #17141a;
}
.gf__head {
display: flex;
align-items: center;
gap: 12px;
}
.gf__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #fff7e6, #ff9ebb);
border: 1px solid rgba(255, 255, 255, 0.55);
color: #17141a;
font-size: 18px;
line-height: 22px;
font-weight: 800;
}
.gf__who {
display: flex;
flex-direction: column;
gap: 4px;
}
.gf__name {
margin: 0;
font-size: 20px;
line-height: 24px;
font-weight: 800;
color: #17141a;
}
.gf__role {
margin: 0;
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: rgba(23, 20, 26, 0.74);
}
.gf__stats {
display: flex;
align-items: center;
gap: 16px;
}
.gf__stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.gf__stat + .gf__stat {
border-left: 1px solid rgba(23, 20, 26, 0.22);
padding-left: 16px;
}
.gf__num {
font-size: 17px;
line-height: 20px;
font-weight: 800;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.gf__lab {
font-size: 11px;
line-height: 13px;
font-weight: 600;
color: rgba(23, 20, 26, 0.74);
}
.gf__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 16px;
background: rgba(255, 255, 255, 0.58);
border: 1px solid rgba(255, 255, 255, 0.82);
border-radius: 999px;
color: #17141a;
font-size: 13px;
line-height: 16px;
font-weight: 700;
cursor: pointer;
transition: background 220ms ease-out, color 220ms ease-out;
}
.gf__btn svg {
display: block;
}
.gf__btn svg[hidden] {
display: none;
}
.gf__btn:hover {
background: rgba(255, 255, 255, 0.72);
}
.gf__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.gf__btn[aria-pressed=true] {
background: #17141a;
border-color: rgba(23, 20, 26, 0.55);
color: #fff7e6;
}
.gf.is-demo .gf__blob--a {
animation: gf-drift-a 2s linear infinite;
}
.gf.is-demo .gf__blob--b {
animation: gf-drift-b 2s linear infinite;
}
@keyframes gf-drift-a {
0%, 100% {
transform: translate(0, 0);
}
36% {
transform: translate(84px, 46px);
}
70% {
transform: translate(148px, 8px);
}
}
@keyframes gf-drift-b {
0%, 100% {
transform: translate(0, 0);
}
40% {
transform: translate(-64px, -38px);
}
76% {
transform: translate(-132px, -10px);
}
}
@media (max-width: 340px) {
.gf {
width: 272px;
height: 146px;
}
.gf__card {
gap: 4px;
padding: 0 12px;
}
.gf__head {
gap: 8px;
}
.gf__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 18px;
}
.gf__who {
gap: 0;
}
.gf__name {
font-size: 14px;
line-height: 18px;
}
.gf__role {
font-size: 11px;
line-height: 13px;
}
.gf__stats {
gap: 12px;
}
.gf__stat {
gap: 0;
}
.gf__stat + .gf__stat {
padding-left: 12px;
}
.gf__num {
font-size: 13px;
line-height: 16px;
}
.gf__lab {
font-size: 11px;
line-height: 13px;
}
.gf__btn {
height: 32px;
padding: 0 12px;
font-size: 12px;
line-height: 14px;
}
.gf__blob--a {
width: 104px;
height: 104px;
}
.gf__blob--b {
width: 116px;
height: 116px;
top: 32px;
left: 148px;
}
}
@media (prefers-reduced-motion: reduce) {
.gf.is-demo .gf__blob--a,
.gf.is-demo .gf__blob--b {
animation: none !important;
}
.gf__btn {
transition: none;
}
}// 실제 동작: フォロー 버튼 토글 — 아이콘 두 개를 hidden 으로 교체한다. 첫 조작에 자동 루프가 끊긴다.
var root = document.querySelector('.gf');
var btn = document.getElementById('gf-btn');
var label = document.getElementById('gf-label');
var icons = btn.querySelectorAll('svg');
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', String(on));
icons[0].hidden = on;
icons[1].hidden = !on;
label.textContent = on ? 'フォロー中' : 'フォロー';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./GlassFrostCard.module.scss";
export type GlassFrostCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconFollow = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function GlassFrostCard({
duration = 220,
easing = "ease-out",
color = "#fff7e6",
}: GlassFrostCardProps) {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle(e: ClickEvt): void {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
}
return (
<div className={styles.gf}>
<div className={styles.blobA} aria-hidden="true" />
<div className={styles.blobB} aria-hidden="true" />
<article className={styles.card}>
<div className={styles.head}>
<div className={styles.avatar}>SR</div>
<div className={styles.who}>
<p className={styles.name}>이서린</p>
<p className={styles.role}>프로덕트 디자이너</p>
</div>
</div>
<div className={styles.stats}>
<div className={styles.stat}><strong className={styles.num}>2,431</strong><span className={styles.lab}>팔로워</span></div>
<div className={styles.stat}><strong className={styles.num}>128</strong><span className={styles.lab}>게시물</span></div>
</div>
<button type="button" className={styles.btn} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconFollow />}
<span>{on ? "팔로잉" : "팔로우"}</span>
</button>
</article>
</div>
);
}01 · グラスモーフィズム — 計測 累積面積 33.061% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
曇りガラスを作るのは backdrop-filter の1行ですが、その後ろにぼける対象が実際にないと効果は見えません。
.gf__card {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, .30);
border: 1px solid rgba(255, 255, 255, .62);
border-radius: $r-card;
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
color: $subject-ink;
}
.gf__blob--a {
top: -24px; left: -30px;
width: 140px; height: 140px;
background: radial-gradient(circle at 38% 38%, $subject-pink, rgba($subject-pink, 0) 72%);
}
02ネオブルータリズム オフセット
画面で起きること
太い枠と右下にずらした単色の影が、カードを切り抜いた紙のように浮かせます。

<main class="stage">
<div class="nb is-demo">
<article class="nb__card">
<div class="nb__head">
<div class="nb__avatar">DH</div>
<div class="nb__who">
<p class="nb__name">パク・ドヒョン</p>
<p class="nb__role">フロントエンド開発者</p>
</div>
</div>
<div class="nb__stats">
<div class="nb__stat"><strong class="nb__num">1,842</strong><span class="nb__lab">フォロワー</span></div>
<div class="nb__stat"><strong class="nb__num">36</strong><span class="nb__lab">投稿</span></div>
</div>
<button class="nb__btn" type="button" id="nb-btn" aria-pressed="false">
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" hidden><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span id="nb-label">フォロー</span>
</button>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
// 네오브루탈리즘 — 3px 잉크 테두리 + 오프셋 단색 그림자. 누르면 그림자 자리로 내려앉는다.
.nb {
position: relative;
width: 308px; // 카드 300 + 그림자 8 (눌림 여유)
height: 204px; // 카드 196 + 8
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
.nb__card {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-3;
width: 300px;
height: 196px;
padding: 0 $sp-4;
background: #fff;
border: 3px solid $subject-ink;
border-radius: $r-card;
box-shadow: 8px 8px 0 $subject-ink;
color: $subject-ink;
}
.nb__head { display: flex; align-items: center; gap: $sp-3; }
.nb__avatar {
display: flex; align-items: center; justify-content: center;
box-sizing: border-box;
width: 56px; height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, $subject-sky, $subject-blue);
border: 3px solid $subject-ink;
color: $subject-ink;
font-size: 17px; line-height: 21px; font-weight: 800;
}
.nb__who { display: flex; flex-direction: column; align-items: flex-start; gap: $sp-1; }
.nb__name { margin: 0; font-size: 20px; line-height: 24px; font-weight: 800; }
.nb__role {
margin: 0;
display: inline-flex; align-items: center;
padding: $sp-1 $sp-2;
background: $color;
border-radius: $r-pill;
color: #fff;
font-size: 11px; line-height: 13px; font-weight: 700;
}
.nb__stats { display: flex; gap: $sp-2; }
.nb__stat {
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2;
background: $stage-yellow;
border: 3px solid $subject-ink;
border-radius: $r-sm;
}
.nb__num { font-size: 17px; line-height: 20px; font-weight: 800; font-variant-numeric: tabular-nums; }
.nb__lab { font-size: 11px; line-height: 13px; font-weight: 700; }
.nb__btn {
display: inline-flex; align-items: center; justify-content: center;
gap: $sp-1;
height: 36px;
padding: 0 $sp-4;
background: $subject-ink;
border: 3px solid $subject-ink;
border-radius: $r-control;
box-shadow: 4px 4px 0 $subject-ink;
color: #fff;
font-size: 13px; line-height: 16px; font-weight: 700;
cursor: pointer;
transition: transform $duration $easing, box-shadow $duration $easing, background $duration $easing;
}
.nb__btn svg { display: block; }
.nb__btn svg[hidden] { display: none; }
.nb__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.nb__btn[aria-pressed="true"] { background: $color; }
.nb__card:active { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
.nb__btn:active { transform: translate(4px, 4px); box-shadow: 0 0 0 $subject-ink; }
// ── 자동 루프: 카드가 그림자 자리로 내려앉았다 올라온다(두 번). 복귀는 68% 안에 끝 — 끝 프레임 = 첫 프레임 ──
.nb.is-demo .nb__card { animation: nb-press $dur-loop $ease-out infinite; }
@keyframes nb-press {
0%, 100% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
9% { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
20% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
46% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
55% { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
68% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
}
@media (max-width: 340px) {
.nb { width: 280px; height: 158px; }
.nb__card { width: 272px; height: 150px; gap: $sp-1; padding: 0 $sp-3; }
.nb__head { gap: $sp-2; }
.nb__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 17px; }
.nb__who { gap: 0; }
.nb__name { font-size: 14px; line-height: 18px; }
.nb__stats { gap: $sp-1; }
.nb__stat { gap: 0; padding: $sp-1; }
.nb__num { font-size: 13px; line-height: 16px; }
.nb__lab { font-size: 11px; line-height: 13px; }
.nb__btn { height: 32px; padding: 0 $sp-3; font-size: 12px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.nb.is-demo .nb__card { animation: none !important; }
.nb__card, .nb__btn { transition: none; }
}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);
}
.nb {
position: relative;
width: 308px;
height: 204px;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
.nb__card {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 300px;
height: 196px;
padding: 0 16px;
background: #fff;
border: 3px solid #17141a;
border-radius: 16px;
box-shadow: 8px 8px 0 #17141a;
color: #17141a;
}
.nb__head {
display: flex;
align-items: center;
gap: 12px;
}
.nb__avatar {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #6ec8ff, #2f6df6);
border: 3px solid #17141a;
color: #17141a;
font-size: 17px;
line-height: 21px;
font-weight: 800;
}
.nb__who {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.nb__name {
margin: 0;
font-size: 20px;
line-height: 24px;
font-weight: 800;
}
.nb__role {
margin: 0;
display: inline-flex;
align-items: center;
padding: 4px 8px;
background: #2f6df6;
border-radius: 999px;
color: #fff;
font-size: 11px;
line-height: 13px;
font-weight: 700;
}
.nb__stats {
display: flex;
gap: 8px;
}
.nb__stat {
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
background: #ffd23f;
border: 3px solid #17141a;
border-radius: 8px;
}
.nb__num {
font-size: 17px;
line-height: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.nb__lab {
font-size: 11px;
line-height: 13px;
font-weight: 700;
}
.nb__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 16px;
background: #17141a;
border: 3px solid #17141a;
border-radius: 12px;
box-shadow: 4px 4px 0 #17141a;
color: #fff;
font-size: 13px;
line-height: 16px;
font-weight: 700;
cursor: pointer;
transition: transform 220ms ease-out, box-shadow 220ms ease-out, background 220ms ease-out;
}
.nb__btn svg {
display: block;
}
.nb__btn svg[hidden] {
display: none;
}
.nb__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.nb__btn[aria-pressed=true] {
background: #2f6df6;
}
.nb__card:active {
transform: translate(8px, 8px);
box-shadow: 0 0 0 #17141a;
}
.nb__btn:active {
transform: translate(4px, 4px);
box-shadow: 0 0 0 #17141a;
}
.nb.is-demo .nb__card {
animation: nb-press 2s ease-out infinite;
}
@keyframes nb-press {
0%, 100% {
transform: translate(0, 0);
box-shadow: 8px 8px 0 #17141a;
}
9% {
transform: translate(8px, 8px);
box-shadow: 0 0 0 #17141a;
}
20% {
transform: translate(0, 0);
box-shadow: 8px 8px 0 #17141a;
}
46% {
transform: translate(0, 0);
box-shadow: 8px 8px 0 #17141a;
}
55% {
transform: translate(8px, 8px);
box-shadow: 0 0 0 #17141a;
}
68% {
transform: translate(0, 0);
box-shadow: 8px 8px 0 #17141a;
}
}
@media (max-width: 340px) {
.nb {
width: 280px;
height: 158px;
}
.nb__card {
width: 272px;
height: 150px;
gap: 4px;
padding: 0 12px;
}
.nb__head {
gap: 8px;
}
.nb__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 17px;
}
.nb__who {
gap: 0;
}
.nb__name {
font-size: 14px;
line-height: 18px;
}
.nb__stats {
gap: 4px;
}
.nb__stat {
gap: 0;
padding: 4px;
}
.nb__num {
font-size: 13px;
line-height: 16px;
}
.nb__lab {
font-size: 11px;
line-height: 13px;
}
.nb__btn {
height: 32px;
padding: 0 12px;
font-size: 12px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.nb.is-demo .nb__card {
animation: none !important;
}
.nb__card, .nb__btn {
transition: none;
}
}// 실제 동작: フォロー 버튼 토글. 첫 조작에 자동 루프가 끊기고 :active 눌림이 그대로 살아 있다.
var root = document.querySelector('.nb');
var btn = document.getElementById('nb-btn');
var label = document.getElementById('nb-label');
var icons = btn.querySelectorAll('svg');
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', String(on));
icons[0].hidden = on;
icons[1].hidden = !on;
label.textContent = on ? 'フォロー中' : 'フォロー';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./NeoBrutalCard.module.scss";
export type NeoBrutalCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconFollow = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function NeoBrutalCard({
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: NeoBrutalCardProps) {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle(e: ClickEvt): void {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
}
return (
<div className={styles.nb}>
<article className={styles.card}>
<div className={styles.head}>
<div className={styles.avatar}>DH</div>
<div className={styles.who}>
<p className={styles.name}>박도현</p>
<p className={styles.role}>프론트엔드 개발자</p>
</div>
</div>
<div className={styles.stats}>
<div className={styles.stat}><strong className={styles.num}>1,842</strong><span className={styles.lab}>팔로워</span></div>
<div className={styles.stat}><strong className={styles.num}>36</strong><span className={styles.lab}>게시물</span></div>
</div>
<button type="button" className={styles.btn} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconFollow />}
<span>{on ? "팔로잉" : "팔로우"}</span>
</button>
</article>
</div>
);
}02 · ネオブルータリズム オフセット — 計測 累積面積 18.2% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
押すとカードは影の位置にぴったり沈み、影はその下に消えます。
.nb__card {
border: 3px solid $subject-ink;
border-radius: $r-card;
box-shadow: 8px 8px 0 $subject-ink;
}
@keyframes nb-press {
0%, 100% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
9% { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
20% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
46% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
55% { transform: translate(8px, 8px); box-shadow: 0 0 0 $subject-ink; }
68% { transform: translate(0, 0); box-shadow: 8px 8px 0 $subject-ink; }
}
03クレーモーフィズム
どんな動きか
影を3枚重ねることで、カードがふくらんだ粘土のように見えます。

<main class="stage">
<article class="cp is-demo">
<div class="cp__head">
<div class="cp__avatar">HY</div>
<div class="cp__who">
<p class="cp__name">チョン・ハユン</p>
<p class="cp__role">イラストレーター</p>
</div>
</div>
<div class="cp__stats">
<div class="cp__stat"><strong class="cp__num">5,108</strong><span class="cp__lab">フォロワー</span></div>
<div class="cp__stat"><strong class="cp__num">94</strong><span class="cp__lab">投稿</span></div>
</div>
<button class="cp__btn" type="button" id="cp-btn" aria-pressed="false">
<svg viewBox="0 0 256 256" width="16" height="16" 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.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true" hidden><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span id="cp-label">フォロー</span>
</button>
</article>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-lilac;
@include stage($stage-paper);
// 클레이모피즘 — 파스텔 바탕 + 겹친 인셋 그림자로 부풀린 카드. 아바타가 숨 쉰다.
.cp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: 300px;
padding: $sp-4;
border-radius: $r-card;
background: rgba($subject-pink, .28);
box-shadow:
0 18px 30px rgba(23, 20, 26, .14),
inset 6px 6px 12px rgba(255, 255, 255, .9),
inset -6px -6px 12px rgba(23, 20, 26, .10);
color: $subject-ink;
user-select: none;
-webkit-user-select: none;
}
.cp__head { display: flex; align-items: center; gap: $sp-3; }
.cp__avatar {
display: flex; align-items: center; justify-content: center;
width: 56px; height: 56px;
border-radius: 50%;
background: $color;
box-shadow:
inset 4px 4px 10px rgba(255, 255, 255, .85),
inset -4px -4px 10px rgba(23, 20, 26, .12);
color: $subject-ink;
font-size: 18px; line-height: 22px; font-weight: 800;
}
.cp__who { display: flex; flex-direction: column; gap: $sp-1; }
.cp__name { margin: 0; font-size: 20px; line-height: 24px; font-weight: 800; }
.cp__role { margin: 0; font-size: 12px; line-height: 16px; font-weight: 600; color: rgba(23, 20, 26, .62); }
.cp__stats { display: flex; align-items: center; gap: $sp-4; }
.cp__stat { display: flex; flex-direction: column; align-items: center; gap: $sp-1; }
.cp__stat + .cp__stat { border-left: 1px solid rgba(23, 20, 26, .14); padding-left: $sp-4; }
.cp__num { font-size: 17px; line-height: 20px; font-weight: 800; font-variant-numeric: tabular-nums; }
.cp__lab { font-size: 11px; line-height: 13px; font-weight: 600; color: rgba(23, 20, 26, .62); }
.cp__btn {
display: inline-flex; align-items: center; justify-content: center;
gap: $sp-1;
height: 36px;
padding: 0 $sp-5;
border: 0;
border-radius: $r-pill;
background: rgba($subject-lilac, .6);
box-shadow:
0 8px 16px rgba(23, 20, 26, .14),
inset 3px 3px 8px rgba(255, 255, 255, .85),
inset -3px -3px 8px rgba(23, 20, 26, .10);
color: $subject-ink;
font-size: 13px; line-height: 16px; font-weight: 800;
cursor: pointer;
transition: background $duration $easing, transform $duration $easing;
}
.cp__btn svg { display: block; }
.cp__btn svg[hidden] { display: none; }
.cp__btn:hover { background: rgba($subject-lilac, .78); }
.cp__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.cp__btn:active { transform: scale(.95); }
.cp__btn[aria-pressed="true"] { background: rgba($subject-mint, .55); }
// ── 자동 루프: 아바타가 숨 쉬듯 부풀고(복귀 88% 안), 버튼은 시차를 두고 살짝 부풀린다 ──
.cp.is-demo .cp__avatar {
animation-name: cp-breathe;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.cp.is-demo .cp__btn {
animation-name: cp-puff;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: -600ms; // 시차 — 단축 속성은 delay 를 리셋하므로 낱개 속성으로 쓴다
}
@keyframes cp-breathe {
0%, 100% { transform: scale(1); }
44% { transform: scale(1.12); }
88% { transform: scale(1); }
}
@keyframes cp-puff {
0%, 100% { transform: scale(1); }
44% { transform: scale(1.07); }
88% { transform: scale(1); }
}
@media (max-width: 340px) {
.cp { width: 272px; gap: $sp-1; padding: $sp-3; }
.cp__head { gap: $sp-2; }
.cp__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 18px; }
.cp__who { gap: 0; }
.cp__name { font-size: 14px; line-height: 18px; }
.cp__role { font-size: 11px; line-height: 13px; }
.cp__stats { gap: $sp-3; }
.cp__stat { gap: 0; }
.cp__stat + .cp__stat { padding-left: $sp-3; }
.cp__num { font-size: 13px; line-height: 16px; }
.cp__lab { font-size: 11px; line-height: 13px; }
.cp__btn { height: 32px; padding: 0 $sp-4; font-size: 12px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.cp.is-demo .cp__avatar,
.cp.is-demo .cp__btn { animation: none !important; }
.cp__btn { transition: 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);
}
.cp {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 300px;
padding: 16px;
border-radius: 16px;
background: rgba(255, 158, 187, 0.28);
box-shadow: 0 18px 30px rgba(23, 20, 26, 0.14), inset 6px 6px 12px rgba(255, 255, 255, 0.9), inset -6px -6px 12px rgba(23, 20, 26, 0.1);
color: #17141a;
user-select: none;
-webkit-user-select: none;
}
.cp__head {
display: flex;
align-items: center;
gap: 12px;
}
.cp__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: #b9a0ff;
box-shadow: inset 4px 4px 10px rgba(255, 255, 255, 0.85), inset -4px -4px 10px rgba(23, 20, 26, 0.12);
color: #17141a;
font-size: 18px;
line-height: 22px;
font-weight: 800;
}
.cp__who {
display: flex;
flex-direction: column;
gap: 4px;
}
.cp__name {
margin: 0;
font-size: 20px;
line-height: 24px;
font-weight: 800;
}
.cp__role {
margin: 0;
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: rgba(23, 20, 26, 0.62);
}
.cp__stats {
display: flex;
align-items: center;
gap: 16px;
}
.cp__stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.cp__stat + .cp__stat {
border-left: 1px solid rgba(23, 20, 26, 0.14);
padding-left: 16px;
}
.cp__num {
font-size: 17px;
line-height: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.cp__lab {
font-size: 11px;
line-height: 13px;
font-weight: 600;
color: rgba(23, 20, 26, 0.62);
}
.cp__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 20px;
border: 0;
border-radius: 999px;
background: rgba(185, 160, 255, 0.6);
box-shadow: 0 8px 16px rgba(23, 20, 26, 0.14), inset 3px 3px 8px rgba(255, 255, 255, 0.85), inset -3px -3px 8px rgba(23, 20, 26, 0.1);
color: #17141a;
font-size: 13px;
line-height: 16px;
font-weight: 800;
cursor: pointer;
transition: background 300ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cp__btn svg {
display: block;
}
.cp__btn svg[hidden] {
display: none;
}
.cp__btn:hover {
background: rgba(185, 160, 255, 0.78);
}
.cp__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cp__btn:active {
transform: scale(0.95);
}
.cp__btn[aria-pressed=true] {
background: rgba(76, 212, 166, 0.55);
}
.cp.is-demo .cp__avatar {
animation-name: cp-breathe;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.cp.is-demo .cp__btn {
animation-name: cp-puff;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-delay: -600ms;
}
@keyframes cp-breathe {
0%, 100% {
transform: scale(1);
}
44% {
transform: scale(1.12);
}
88% {
transform: scale(1);
}
}
@keyframes cp-puff {
0%, 100% {
transform: scale(1);
}
44% {
transform: scale(1.07);
}
88% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.cp {
width: 272px;
gap: 4px;
padding: 12px;
}
.cp__head {
gap: 8px;
}
.cp__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 18px;
}
.cp__who {
gap: 0;
}
.cp__name {
font-size: 14px;
line-height: 18px;
}
.cp__role {
font-size: 11px;
line-height: 13px;
}
.cp__stats {
gap: 12px;
}
.cp__stat {
gap: 0;
}
.cp__stat + .cp__stat {
padding-left: 12px;
}
.cp__num {
font-size: 13px;
line-height: 16px;
}
.cp__lab {
font-size: 11px;
line-height: 13px;
}
.cp__btn {
height: 32px;
padding: 0 16px;
font-size: 12px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.cp.is-demo .cp__avatar,
.cp.is-demo .cp__btn {
animation: none !important;
}
.cp__btn {
transition: none;
}
}// 실제 동작: フォロー 버튼 토글. 첫 조작에 자동 루프가 끊긴다.
var root = document.querySelector('.cp');
var btn = document.getElementById('cp-btn');
var label = document.getElementById('cp-label');
var icons = btn.querySelectorAll('svg');
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', String(on));
icons[0].hidden = on;
icons[1].hidden = !on;
label.textContent = on ? 'フォロー中' : 'フォロー';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./ClayPuffCard.module.scss";
export type ClayPuffCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconStar = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" 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.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
);
const IconCheck = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function ClayPuffCard({
duration = 300,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#b9a0ff",
}: ClayPuffCardProps) {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle(e: ClickEvt): void {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
}
return (
<article className={styles.cp}>
<div className={styles.head}>
<div className={styles.avatar}>HY</div>
<div className={styles.who}>
<p className={styles.name}>정하윤</p>
<p className={styles.role}>일러스트레이터</p>
</div>
</div>
<div className={styles.stats}>
<div className={styles.stat}><strong className={styles.num}>5,108</strong><span className={styles.lab}>팔로워</span></div>
<div className={styles.stat}><strong className={styles.num}>94</strong><span className={styles.lab}>게시물</span></div>
</div>
<button type="button" className={styles.btn} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconStar />}
<span>{on ? "팔로잉" : "팔로우"}</span>
</button>
</article>
);
}03 · クレイモーフィズム — 計測 累積面積 1.766% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
外へ広がる影ひとつに、内側の明るいハイライトと内側の暗いくぼみを足した組み合わせが、もちっとした厚みになります。
.cp__card {
border-radius: $r-card;
background: rgba($subject-pink, .28);
box-shadow:
0 18px 30px rgba(23, 20, 26, .14),
inset 6px 6px 12px rgba(255, 255, 255, .9),
inset -6px -6px 12px rgba(23, 20, 26, .10);
}
@keyframes cp-breathe {
0%, 100% { transform: scale(1); }
44% { transform: scale(1.12); }
88% { transform: scale(1); }
}
04ミニマルライン
何が見えるか
白い背景に白いカードなので、背景との差がありません。

<main class="stage">
<div class="ml is-demo">
<article class="ml__card">
<div class="ml__avatar" aria-hidden="true">YW</div>
<h2 class="ml__name">チェ・ヨヌ<span class="ml__line ml__line--name" aria-hidden="true"></span></h2>
<p class="ml__role">ブランド戦略担当</p>
<p class="ml__stats">
<span class="ml__stat"><strong>812</strong>フォロワー</span>
<span class="ml__stat"><strong>41</strong>投稿</span>
</p>
<button class="ml__follow" type="button" id="ml-follow" aria-pressed="false">
<svg class="ml__i ml__i--plus" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
<svg class="ml__i ml__i--check" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span class="ml__label" id="ml-label">フォロー</span>
<span class="ml__line ml__line--btn" aria-hidden="true"></span>
</button>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.ml {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
// 흰 무대 위 흰 카드 — 1px 선과 여백만으로 형태를 만든다
.ml__card {
display: flex;
flex-direction: column;
align-items: center;
width: 300px;
padding: $sp-4;
background: #fff;
border: 1px solid rgba(23, 20, 26, .14);
border-radius: $r-card;
}
.ml__avatar {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border: 1px solid rgba(23, 20, 26, .22);
border-radius: 50%;
background: rgba($subject-blue, .08);
color: $subject-ink;
font-size: 17px;
line-height: 20px;
font-weight: 700;
}
.ml__name {
margin: $sp-2 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: $subject-ink;
text-align: center;
}
.ml__role {
margin: $sp-1 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(23, 20, 26, .64);
}
// 밑줄 — 왼쪽부터 scaleX 로 그어지고, 왼쪽으로 접혀 돌아간다 (origin 고정)
.ml__line {
display: block;
height: 2px;
background: $subject-ink;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 밑줄 left에서 그어짐
transition: transform $duration $easing;
}
.ml__line--name { width: 56px; margin: $sp-1 auto 0; }
.ml__line--btn {
position: absolute;
left: -1px;
bottom: -1px;
width: calc(100% + 2px);
background: $color;
border-bottom-left-radius: $r-control;
border-bottom-right-radius: $r-control;
}
.ml__stats {
display: flex;
gap: $sp-4;
margin: $sp-3 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(23, 20, 26, .64);
}
.ml__stat strong {
margin-right: $sp-1;
font-weight: 700;
color: $subject-ink;
font-variant-numeric: tabular-nums;
}
.ml__follow {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-1;
height: 36px;
margin: $sp-3 0 0;
padding: 0 $sp-4;
background: none;
border: 1px solid rgba(23, 20, 26, .14);
border-radius: $r-control;
color: $subject-ink;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
transition: border-color $dur-quick $easing;
}
.ml__follow:hover { border-color: rgba(23, 20, 26, .3); }
.ml__follow:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ml__i { display: inline-flex; }
.ml__i--check { display: none; color: $color; }
.ml__follow[aria-pressed="true"] .ml__i--plus { display: none; }
.ml__follow[aria-pressed="true"] .ml__i--check { display: inline-flex; }
// 실제 상호작용: 호버·키보드 포커스·팔로잉 상태에서 같은 밑줄이 그어진다
.ml__name:hover .ml__line--name,
.ml__follow:hover .ml__line--btn,
.ml__follow:focus-visible .ml__line--btn,
.ml__follow[aria-pressed="true"] .ml__line--btn { transform: scaleX(1); }
// ── 자동 루프: 이름 밑줄 → 버튼 아래 테두리(167ms 시차). 복귀는 88% 안 ──
// poster 는 "직전 프레임 대비 가장 크게 변한 프레임" 이라 등속·ease-out 으로 그으면
// 시작 직후의 짧은 작대기가 대표 이미지로 뽑힌다 (실측 2026-09-12). 그래서 마지막 한 칸을
// 한 샘플 간격(4.1667% = 83.3ms) 안에 몰아 넣어, 선이 끝까지 그어지는 프레임이 최대 변화가 되게 한다.
// 버튼 선의 시차 167ms 는 샘플 간격의 정확히 2배 — 컷이 프레임 사이에 딱 떨어진다.
// 되돌아오기는 linear — 한 프레임에 몰리면 그 프레임이 poster 를 뺏는다.
.ml.is-demo .ml__line--name {
animation-name: ml-draw-name;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ml.is-demo .ml__line--btn {
animation-name: ml-draw-btn;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-delay: 167ms;
animation-fill-mode: backwards;
}
@keyframes ml-draw-name {
0% { transform: scaleX(0); }
25% { transform: scaleX(.7); }
29% { transform: scaleX(1); }
54% { transform: scaleX(1); }
80% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
@keyframes ml-draw-btn {
0% { transform: scaleX(0); }
25% { transform: scaleX(.4); }
29% { transform: scaleX(1); }
54% { transform: scaleX(1); }
80% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
@media (max-width: 340px) {
.ml__card { width: 272px; padding: $sp-3; }
.ml__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 16px; }
.ml__name { margin-top: $sp-1; font-size: 13px; line-height: 16px; }
.ml__line--name { width: 44px; margin-top: 0; }
.ml__role { margin-top: 0; font-size: 11px; line-height: 14px; }
.ml__stats { margin-top: $sp-1; gap: $sp-3; font-size: 11px; line-height: 15px; }
.ml__follow { height: 32px; margin-top: $sp-1; padding: 0 $sp-3; font-size: 12px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.ml.is-demo .ml__line--name,
.ml.is-demo .ml__line--btn { animation: none !important; }
.ml__line { transition: 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);
}
.ml {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
.ml__card {
display: flex;
flex-direction: column;
align-items: center;
width: 300px;
padding: 16px;
background: #fff;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
}
.ml__avatar {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border: 1px solid rgba(23, 20, 26, 0.22);
border-radius: 50%;
background: rgba(47, 109, 246, 0.08);
color: #17141a;
font-size: 17px;
line-height: 20px;
font-weight: 700;
}
.ml__name {
margin: 8px 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: #17141a;
text-align: center;
}
.ml__role {
margin: 4px 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(23, 20, 26, 0.64);
}
.ml__line {
display: block;
height: 2px;
background: #17141a;
transform: scaleX(0);
transform-origin: left center;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ml__line--name {
width: 56px;
margin: 4px auto 0;
}
.ml__line--btn {
position: absolute;
left: -1px;
bottom: -1px;
width: calc(100% + 2px);
background: #2f6df6;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.ml__stats {
display: flex;
gap: 16px;
margin: 12px 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(23, 20, 26, 0.64);
}
.ml__stat strong {
margin-right: 4px;
font-weight: 700;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.ml__follow {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
margin: 12px 0 0;
padding: 0 16px;
background: none;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 12px;
color: #17141a;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ml__follow:hover {
border-color: rgba(23, 20, 26, 0.3);
}
.ml__follow:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ml__i {
display: inline-flex;
}
.ml__i--check {
display: none;
color: #2f6df6;
}
.ml__follow[aria-pressed=true] .ml__i--plus {
display: none;
}
.ml__follow[aria-pressed=true] .ml__i--check {
display: inline-flex;
}
.ml__name:hover .ml__line--name,
.ml__follow:hover .ml__line--btn,
.ml__follow:focus-visible .ml__line--btn,
.ml__follow[aria-pressed=true] .ml__line--btn {
transform: scaleX(1);
}
.ml.is-demo .ml__line--name {
animation-name: ml-draw-name;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ml.is-demo .ml__line--btn {
animation-name: ml-draw-btn;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-delay: 167ms;
animation-fill-mode: backwards;
}
@keyframes ml-draw-name {
0% {
transform: scaleX(0);
}
25% {
transform: scaleX(0.7);
}
29% {
transform: scaleX(1);
}
54% {
transform: scaleX(1);
}
80% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
@keyframes ml-draw-btn {
0% {
transform: scaleX(0);
}
25% {
transform: scaleX(0.4);
}
29% {
transform: scaleX(1);
}
54% {
transform: scaleX(1);
}
80% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
@media (max-width: 340px) {
.ml__card {
width: 272px;
padding: 12px;
}
.ml__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 16px;
}
.ml__name {
margin-top: 4px;
font-size: 13px;
line-height: 16px;
}
.ml__line--name {
width: 44px;
margin-top: 0;
}
.ml__role {
margin-top: 0;
font-size: 11px;
line-height: 14px;
}
.ml__stats {
margin-top: 4px;
gap: 12px;
font-size: 11px;
line-height: 15px;
}
.ml__follow {
height: 32px;
margin-top: 4px;
padding: 0 12px;
font-size: 12px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.ml.is-demo .ml__line--name,
.ml.is-demo .ml__line--btn {
animation: none !important;
}
.ml__line {
transition: none;
}
}// 실제 동작: フォロー 토글(aria-pressed) · 첫 조작에 자동 루프 종료
var root = document.querySelector('.ml');
var btn = document.getElementById('ml-follow');
var label = document.getElementById('ml-label');
function stopDemo() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopDemo);
root.addEventListener('keydown', stopDemo);
btn.addEventListener('click', function () {
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', on ? 'false' : 'true');
label.textContent = on ? 'フォロー' : 'フォロー中';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./MinimalLineCard.module.scss";
export type MinimalLineCardProps = {
name?: string;
role?: string;
initials?: string;
followers?: string;
posts?: string;
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconUserPlus = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function MinimalLineCard({
name = "최연우",
role = "브랜드 전략가",
initials = "YW",
followers = "812",
posts = "41",
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: MinimalLineCardProps): ReactNode {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const toggle = (e: ClickEvt): void => {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
};
return (
<div className={styles.ml} style={vars}>
<article className={styles.card}>
<div className={styles.avatar} aria-hidden="true">{initials}</div>
<h2 className={styles.name}>
{name}
<span className={styles.nameLine} aria-hidden="true" />
</h2>
<p className={styles.role}>{role}</p>
<p className={styles.stats}>
<span className={styles.stat}><strong>{followers}</strong>팔로워</span>
<span className={styles.stat}><strong>{posts}</strong>게시물</span>
</p>
<button type="button" className={styles.follow} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconUserPlus />}
<span className={styles.label}>{on ? "팔로잉" : "팔로우"}</span>
<span className={styles.btnLine} aria-hidden="true" />
</button>
</article>
</div>
);
}04 · ミニマルライン — 計測 累積面積 0.215% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
1pxの枠線と余白だけで形を作り、名前とボタンの下に左から引かれる下線だけが唯一の飾りです。
.ml__line {
display: block;
height: 2px;
background: $subject-ink;
transform: scaleX(0);
transform-origin: left center;
transition: transform $duration $easing;
}
.ml__name:hover .ml__line--name,
.ml__follow:hover .ml__line--btn,
.ml__follow:focus-visible .ml__line--btn,
.ml__follow[aria-pressed="true"] .ml__line--btn { transform: scaleX(1); }
05回転グラデーション枠
画面で起きること
円錐グラデーションがカードの周りを一周し、内側の板がその上を覆って4pxの枠だけが光として残ります。

<main class="stage">
<div class="gr is-demo">
<div class="gr__rim">
<article class="gr__face">
<div class="gr__avatar" aria-hidden="true">JW</div>
<h2 class="gr__name">ハン・ジウン</h2>
<p class="gr__role">モーションデザイナー</p>
<p class="gr__stats">
<span class="gr__stat"><strong>9,640</strong>フォロワー</span>
<span class="gr__stat"><strong>210</strong>投稿</span>
</p>
<button class="gr__follow" type="button" id="gr-follow" aria-pressed="false">
<svg class="gr__i gr__i--star" viewBox="0 0 256 256" width="16" height="16" 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.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
<svg class="gr__i gr__i--check" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span class="gr__label" id="gr-label">フォロー</span>
</button>
</article>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 회전 각도 — @property 로 등록해야 --angle 이 보간된다 (등록 없으면 테두리가 안 돈다)
@property --angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.gr {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
// 바깥 상자: conic 그라디언트 테두리가 2초에 한 바퀴 돈다 (360deg 순환 — 텔레포트 없음)
.gr__rim {
width: 300px;
padding: $sp-1;
border-radius: $r-card;
background: conic-gradient(from var(--angle),
$subject-blue, $subject-pink, $subject-mint, $subject-lilac, $subject-blue);
animation-name: gr-spin;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes gr-spin { to { --angle: 360deg; } }
.gr__face {
display: flex;
flex-direction: column;
align-items: center;
padding: $sp-4;
border-radius: $r-control;
background-color: $stage-ink;
background-image: linear-gradient(rgba(255, 255, 255, .06), rgba(255, 255, 255, .06));
}
.gr__avatar {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: linear-gradient(135deg, $subject-blue, $subject-pink);
color: $subject-ink;
font-size: 16px;
line-height: 20px;
font-weight: 700;
}
.gr__name {
margin: $sp-2 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: $subject-cream;
}
.gr__role {
margin: $sp-1 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(255, 255, 255, .6);
}
.gr__stats {
display: flex;
gap: $sp-4;
margin: $sp-3 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(255, 255, 255, .6);
}
.gr__stat strong {
margin-right: $sp-1;
font-weight: 700;
color: $subject-cream;
font-variant-numeric: tabular-nums;
}
.gr__follow {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-1;
height: 36px;
margin: $sp-3 0 0;
padding: 0 $sp-4;
background: none;
border: 1px solid rgba(255, 255, 255, .22);
border-radius: $r-control;
color: $subject-cream;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
transition: background $dur-quick $easing, border-color $duration $easing;
}
.gr__follow:hover { background: rgba(255, 255, 255, .08); }
.gr__follow:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.gr__follow[aria-pressed="true"] { border-color: rgba($subject-mint, .65); }
.gr__i { display: inline-flex; }
.gr__i--check { display: none; color: $subject-mint; }
.gr__follow[aria-pressed="true"] .gr__i--star { display: none; }
.gr__follow[aria-pressed="true"] .gr__i--check { display: inline-flex; }
@media (max-width: 340px) {
.gr__rim { width: 272px; }
.gr__face { padding: $sp-3; }
.gr__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 16px; }
.gr__name { margin-top: $sp-1; font-size: 13px; line-height: 16px; }
.gr__role { margin-top: 0; font-size: 11px; line-height: 14px; }
.gr__stats { margin-top: $sp-1; gap: $sp-3; font-size: 11px; line-height: 15px; }
.gr__follow { height: 32px; margin-top: $sp-1; padding: 0 $sp-3; font-size: 12px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.gr__rim { animation: none !important; }
.gr__follow { transition: none; }
}@property --angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
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);
}
.gr {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
.gr__rim {
width: 300px;
padding: 4px;
border-radius: 16px;
background: conic-gradient(from var(--angle), #2f6df6, #ff9ebb, #4cd4a6, #b9a0ff, #2f6df6);
animation-name: gr-spin;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes gr-spin {
to {
--angle: 360deg;
}
}
.gr__face {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
border-radius: 12px;
background-color: #17141a;
background-image: linear-gradient(rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.06));
}
.gr__avatar {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: linear-gradient(135deg, #2f6df6, #ff9ebb);
color: #17141a;
font-size: 16px;
line-height: 20px;
font-weight: 700;
}
.gr__name {
margin: 8px 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: #fff7e6;
}
.gr__role {
margin: 4px 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
}
.gr__stats {
display: flex;
gap: 16px;
margin: 12px 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
}
.gr__stat strong {
margin-right: 4px;
font-weight: 700;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.gr__follow {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
margin: 12px 0 0;
padding: 0 16px;
background: none;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 12px;
color: #fff7e6;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gr__follow:hover {
background: rgba(255, 255, 255, 0.08);
}
.gr__follow:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.gr__follow[aria-pressed=true] {
border-color: rgba(76, 212, 166, 0.65);
}
.gr__i {
display: inline-flex;
}
.gr__i--check {
display: none;
color: #4cd4a6;
}
.gr__follow[aria-pressed=true] .gr__i--star {
display: none;
}
.gr__follow[aria-pressed=true] .gr__i--check {
display: inline-flex;
}
@media (max-width: 340px) {
.gr__rim {
width: 272px;
}
.gr__face {
padding: 12px;
}
.gr__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 16px;
}
.gr__name {
margin-top: 4px;
font-size: 13px;
line-height: 16px;
}
.gr__role {
margin-top: 0;
font-size: 11px;
line-height: 14px;
}
.gr__stats {
margin-top: 4px;
gap: 12px;
font-size: 11px;
line-height: 15px;
}
.gr__follow {
height: 32px;
margin-top: 4px;
padding: 0 12px;
font-size: 12px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.gr__rim {
animation: none !important;
}
.gr__follow {
transition: none;
}
}// 실제 동작: フォロー 토글(aria-pressed) · 첫 조작에 자동 루프 종료 (테두리 회전은 유지)
var root = document.querySelector('.gr');
var btn = document.getElementById('gr-follow');
var label = document.getElementById('gr-label');
function stopDemo() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopDemo);
root.addEventListener('keydown', stopDemo);
btn.addEventListener('click', function () {
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', on ? 'false' : 'true');
label.textContent = on ? 'フォロー' : 'フォロー中';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./GradientRimCard.module.scss";
export type GradientRimCardProps = {
name?: string;
role?: string;
initials?: string;
followers?: string;
posts?: string;
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconStar = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" 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.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
);
const IconCheck = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function GradientRimCard({
name = "한지운",
role = "모션 디자이너",
initials = "JW",
followers = "9,640",
posts = "210",
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: GradientRimCardProps): ReactNode {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const toggle = (e: ClickEvt): void => {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
};
return (
<div className={styles.gr} style={vars}>
<div className={styles.rim}>
<article className={styles.face}>
<div className={styles.avatar} aria-hidden="true">{initials}</div>
<h2 className={styles.name}>{name}</h2>
<p className={styles.role}>{role}</p>
<p className={styles.stats}>
<span className={styles.stat}><strong>{followers}</strong>팔로워</span>
<span className={styles.stat}><strong>{posts}</strong>게시물</span>
</p>
<button type="button" className={styles.follow} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconStar />}
<span className={styles.label}>{on ? "팔로잉" : "팔로우"}</span>
</button>
</article>
</div>
</div>
);
}05 · 回転グラデーション枠 — 計測 累積面積 2.851% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
角度を持つカスタムプロパティを @property で登録することが、この項目のすべてです。
@property --angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.gr__rim {
padding: $sp-1;
background: conic-gradient(from var(--angle),
$subject-blue, $subject-pink, $subject-mint, $subject-lilac, $subject-blue);
animation-name: gr-spin;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes gr-spin { to { --angle: 360deg; } }
06ダークグローリング
どんな動きか
暗いカードの上で、アバターを囲む光のにじみと状態点が呼吸するように明るくなったり弱まったりします。

<main class="stage">
<div class="dg is-demo">
<article class="dg__card">
<div class="dg__ava">
<span class="dg__halo" aria-hidden="true"></span>
<span class="dg__ring" aria-hidden="true"></span>
<span class="dg__avatar" aria-hidden="true"><span class="dg__init">MJ</span></span>
<span class="dg__dot" aria-hidden="true"></span>
</div>
<h2 class="dg__name">ソ・ミンジェ</h2>
<p class="dg__role">サウンドエンジニア</p>
<p class="dg__stats">
<span class="dg__stat"><strong>3,325</strong>フォロワー</span>
<span class="dg__stat"><strong>57</strong>投稿</span>
</p>
<button class="dg__follow" type="button" id="dg-follow" aria-pressed="false">
<svg class="dg__i dg__i--plus" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
<svg class="dg__i dg__i--check" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
<span class="dg__label" id="dg-label">フォロー</span>
</button>
</article>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
.dg {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
.dg__card {
display: flex;
flex-direction: column;
align-items: center;
width: 300px;
padding: $sp-4;
background: rgba(255, 255, 255, .05);
border: 1px solid rgba(255, 255, 255, .10);
border-radius: $r-card;
}
.dg__ava {
position: relative;
width: 68px;
height: 68px;
}
.dg__ring {
position: absolute;
inset: 0;
border: 2px solid $subject-mint;
border-radius: 50%;
box-shadow: 0 0 18px rgba($subject-mint, .55), inset 0 0 10px rgba($subject-mint, .45);
}
.dg__halo {
position: absolute;
inset: -8px;
border-radius: 50%;
box-shadow: 0 0 26px 4px rgba($subject-mint, .55);
opacity: .35;
pointer-events: none;
}
.dg__avatar {
position: absolute;
inset: 8px;
display: grid;
place-items: center;
border-radius: 50%;
background: linear-gradient(rgba($subject-mint, .28), rgba($subject-blue, .20)), rgba(255, 255, 255, .05);
font-size: 16px;
line-height: 20px;
font-weight: 700;
}
.dg__init {
color: $subject-cream;
filter: drop-shadow(0 0 6px rgba($subject-mint, .9));
}
.dg__dot {
position: absolute;
right: 0;
bottom: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: $subject-mint;
box-shadow: 0 0 10px rgba($subject-mint, .8);
}
.dg__name {
margin: $sp-2 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: $subject-cream;
}
.dg__role {
margin: $sp-1 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(255, 255, 255, .6);
}
.dg__stats {
display: flex;
gap: $sp-4;
margin: $sp-3 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(255, 255, 255, .6);
}
.dg__stat strong {
margin-right: $sp-1;
font-weight: 700;
color: $subject-cream;
font-variant-numeric: tabular-nums;
}
.dg__follow {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-1;
height: 36px;
margin: $sp-3 0 0;
padding: 0 $sp-4;
background: rgba($subject-mint, .10);
border: 1px solid rgba($subject-mint, .7);
border-radius: $r-control;
color: $subject-cream;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 0 14px rgba($subject-mint, .3);
transition: box-shadow $duration $easing, background $dur-quick $easing;
}
.dg__follow:hover { background: rgba($subject-mint, .2); box-shadow: 0 0 22px rgba($subject-mint, .55); }
.dg__follow:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.dg__follow[aria-pressed="true"] { box-shadow: 0 0 20px rgba($subject-mint, .5); }
.dg__i { display: inline-flex; }
.dg__i--check { display: none; color: $subject-mint; }
.dg__follow[aria-pressed="true"] .dg__i--plus { display: none; }
.dg__follow[aria-pressed="true"] .dg__i--check { display: inline-flex; }
// ── 자동 루프: 고리 번짐이 숨 쉬듯 오르내린다(아바타 고리·상태 점만 — 카드 바탕·무대는 고정).
// box-shadow 값은 고정하고 halo/dot 의 opacity·scale 만 움직인다. 복귀는 88% 안 ──
.dg.is-demo .dg__halo {
animation-name: dg-breathe;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.dg.is-demo .dg__dot {
animation-name: dg-dot-breathe;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-delay: 120ms;
animation-fill-mode: backwards; // 지연 구간에 선언 상태가 한 프레임 찍히는 것을 막는다
}
// 번짐의 양끝을 벌린다 — backwards 로 첫 프레임의 밝은 장이 빠지면서 강도가 14.3 까지
// 내려가 임계(15) 를 못 넘었다 (실측 2026-09-12). 밝기 폭을 넓혀 다시 올린다
@keyframes dg-breathe {
0% { opacity: .18; transform: scale(.92); }
38% { opacity: 1; transform: scale(1.10); }
62% { opacity: 1; transform: scale(1.10); }
88% { opacity: .18; transform: scale(.92); }
100% { opacity: .18; transform: scale(.92); }
}
@keyframes dg-dot-breathe {
0% { opacity: .4; transform: scale(.86); }
38% { opacity: 1; transform: scale(1.12); }
62% { opacity: 1; transform: scale(1.12); }
88% { opacity: .4; transform: scale(.86); }
100% { opacity: .4; transform: scale(.86); }
}
@media (max-width: 340px) {
.dg__card { width: 272px; padding: $sp-2 $sp-3; }
.dg__ava { width: 56px; height: 56px; }
.dg__avatar { inset: 6px; font-size: 14px; line-height: 16px; }
.dg__halo { inset: -6px; }
.dg__dot { width: 10px; height: 10px; bottom: 0; }
.dg__name { margin-top: $sp-1; font-size: 13px; line-height: 16px; }
.dg__role { margin-top: 0; font-size: 11px; line-height: 14px; }
.dg__stats { margin-top: $sp-1; gap: $sp-3; font-size: 11px; line-height: 15px; }
.dg__follow { height: 32px; margin-top: $sp-1; padding: 0 $sp-3; font-size: 12px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.dg.is-demo .dg__halo,
.dg.is-demo .dg__dot { animation: none !important; }
.dg__follow { transition: 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);
}
.dg {
display: grid;
place-items: center;
user-select: none;
-webkit-user-select: none;
}
.dg__card {
display: flex;
flex-direction: column;
align-items: center;
width: 300px;
padding: 16px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
}
.dg__ava {
position: relative;
width: 68px;
height: 68px;
}
.dg__ring {
position: absolute;
inset: 0;
border: 2px solid #4cd4a6;
border-radius: 50%;
box-shadow: 0 0 18px rgba(76, 212, 166, 0.55), inset 0 0 10px rgba(76, 212, 166, 0.45);
}
.dg__halo {
position: absolute;
inset: -8px;
border-radius: 50%;
box-shadow: 0 0 26px 4px rgba(76, 212, 166, 0.55);
opacity: 0.35;
pointer-events: none;
}
.dg__avatar {
position: absolute;
inset: 8px;
display: grid;
place-items: center;
border-radius: 50%;
background: linear-gradient(rgba(76, 212, 166, 0.28), rgba(47, 109, 246, 0.2)), rgba(255, 255, 255, 0.05);
font-size: 16px;
line-height: 20px;
font-weight: 700;
}
.dg__init {
color: #fff7e6;
filter: drop-shadow(0 0 6px rgba(76, 212, 166, 0.9));
}
.dg__dot {
position: absolute;
right: 0;
bottom: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #4cd4a6;
box-shadow: 0 0 10px rgba(76, 212, 166, 0.8);
}
.dg__name {
margin: 8px 0 0;
font-size: 16px;
line-height: 20px;
font-weight: 700;
color: #fff7e6;
}
.dg__role {
margin: 4px 0 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
}
.dg__stats {
display: flex;
gap: 16px;
margin: 12px 0 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
}
.dg__stat strong {
margin-right: 4px;
font-weight: 700;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.dg__follow {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
margin: 12px 0 0;
padding: 0 16px;
background: rgba(76, 212, 166, 0.1);
border: 1px solid rgba(76, 212, 166, 0.7);
border-radius: 12px;
color: #fff7e6;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 0 14px rgba(76, 212, 166, 0.3);
transition: box-shadow 600ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dg__follow:hover {
background: rgba(76, 212, 166, 0.2);
box-shadow: 0 0 22px rgba(76, 212, 166, 0.55);
}
.dg__follow:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.dg__follow[aria-pressed=true] {
box-shadow: 0 0 20px rgba(76, 212, 166, 0.5);
}
.dg__i {
display: inline-flex;
}
.dg__i--check {
display: none;
color: #4cd4a6;
}
.dg__follow[aria-pressed=true] .dg__i--plus {
display: none;
}
.dg__follow[aria-pressed=true] .dg__i--check {
display: inline-flex;
}
.dg.is-demo .dg__halo {
animation-name: dg-breathe;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.dg.is-demo .dg__dot {
animation-name: dg-dot-breathe;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 120ms;
animation-fill-mode: backwards;
}
@keyframes dg-breathe {
0% {
opacity: 0.18;
transform: scale(0.92);
}
38% {
opacity: 1;
transform: scale(1.1);
}
62% {
opacity: 1;
transform: scale(1.1);
}
88% {
opacity: 0.18;
transform: scale(0.92);
}
100% {
opacity: 0.18;
transform: scale(0.92);
}
}
@keyframes dg-dot-breathe {
0% {
opacity: 0.4;
transform: scale(0.86);
}
38% {
opacity: 1;
transform: scale(1.12);
}
62% {
opacity: 1;
transform: scale(1.12);
}
88% {
opacity: 0.4;
transform: scale(0.86);
}
100% {
opacity: 0.4;
transform: scale(0.86);
}
}
@media (max-width: 340px) {
.dg__card {
width: 272px;
padding: 8px 12px;
}
.dg__ava {
width: 56px;
height: 56px;
}
.dg__avatar {
inset: 6px;
font-size: 14px;
line-height: 16px;
}
.dg__halo {
inset: -6px;
}
.dg__dot {
width: 10px;
height: 10px;
bottom: 0;
}
.dg__name {
margin-top: 4px;
font-size: 13px;
line-height: 16px;
}
.dg__role {
margin-top: 0;
font-size: 11px;
line-height: 14px;
}
.dg__stats {
margin-top: 4px;
gap: 12px;
font-size: 11px;
line-height: 15px;
}
.dg__follow {
height: 32px;
margin-top: 4px;
padding: 0 12px;
font-size: 12px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.dg.is-demo .dg__halo,
.dg.is-demo .dg__dot {
animation: none !important;
}
.dg__follow {
transition: none;
}
}// 실제 동작: フォロー 토글(aria-pressed) · 첫 조작에 자동 루프 종료 (정적 번짐은 유지)
var root = document.querySelector('.dg');
var btn = document.getElementById('dg-follow');
var label = document.getElementById('dg-label');
function stopDemo() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopDemo);
root.addEventListener('keydown', stopDemo);
btn.addEventListener('click', function () {
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', on ? 'false' : 'true');
label.textContent = on ? 'フォロー' : 'フォロー中';
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./DarkGlowCard.module.scss";
export type DarkGlowCardProps = {
name?: string;
role?: string;
initials?: string;
followers?: string;
posts?: string;
duration?: number;
easing?: string;
color?: string;
};
type ClickEvt = { currentTarget: { blur(): void } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconUserPlus = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function DarkGlowCard({
name = "서민재",
role = "사운드 엔지니어",
initials = "MJ",
followers = "3,325",
posts = "57",
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: DarkGlowCardProps): ReactNode {
const [on, setOn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const toggle = (e: ClickEvt): void => {
setOn((v: boolean): boolean => !v);
e.currentTarget.blur();
};
return (
<div className={styles.dg} style={vars}>
<article className={styles.card}>
<div className={styles.ava}>
<span className={styles.halo} aria-hidden="true" />
<span className={styles.ring} aria-hidden="true" />
<span className={styles.avatar} aria-hidden="true"><span className={styles.init}>{initials}</span></span>
<span className={styles.dot} aria-hidden="true" />
</div>
<h2 className={styles.name}>{name}</h2>
<p className={styles.role}>{role}</p>
<p className={styles.stats}>
<span className={styles.stat}><strong>{followers}</strong>팔로워</span>
<span className={styles.stat}><strong>{posts}</strong>게시물</span>
</p>
<button type="button" className={styles.follow} aria-pressed={on} onClick={toggle}>
{on ? <IconCheck /> : <IconUserPlus />}
<span className={styles.label}>{on ? "팔로잉" : "팔로우"}</span>
</button>
</article>
</div>
);
}06 · ダークグローリング — 計測 累積面積 5.456% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
箱には box-shadow を内と外に掛け、文字には輪郭をたどる drop-shadow を掛けて、にじみを使い分けます。
.dg__ring {
border: 2px solid $subject-mint;
border-radius: 50%;
box-shadow: 0 0 18px rgba($subject-mint, .55), inset 0 0 10px rgba($subject-mint, .45);
}
.dg__init {
color: $subject-cream;
filter: drop-shadow(0 0 6px rgba($subject-mint, .9));
}
@keyframes dg-breathe {
0% { opacity: .18; transform: scale(.92); }
38% { opacity: 1; transform: scale(1.10); }
62% { opacity: 1; transform: scale(1.10); }
}
073Dフリップ連絡先
何が見えるか
カードが縦軸を中心に半回転し、表の紹介が裏の連絡先に変わります。

<main class="stage">
<div class="fc is-demo">
<div class="fc__inner" id="fc-inner">
<div class="fc__face fc__face--front" id="fc-front">
<span class="fc__avatar" aria-hidden="true">SA</span>
<p class="fc__name">オ・セア</p>
<p class="fc__role">UXリサーチャー</p>
<p class="fc__stats"><span class="fc__num">1,204</span> フォロワー · <span class="fc__num">62</span> 投稿</p>
<div class="fc__actions">
<button class="fc__btn fc__btn--follow" type="button" id="fc-follow" aria-pressed="false">
<span class="fc__f-off"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>フォロー</span>
<span class="fc__f-on"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>フォロー中</span>
</button>
<button class="fc__btn fc__btn--ghost" type="button" id="fc-flip" aria-pressed="false">
<svg viewBox="0 0 256 256" width="14" height="14" 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>連絡先を見る
</button>
</div>
</div>
<div class="fc__face fc__face--back" id="fc-back" aria-hidden="true">
<ul class="fc__list">
<li class="fc__row">
<span class="fc__ic"><svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><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></span>
<span class="fc__meta"><span class="fc__k">メール</span><span class="fc__v">[email protected]</span></span>
</li>
<li class="fc__row">
<span class="fc__ic"><svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M223.94 174.08A48.33 48.33 0 0 1 176 216A136 136 0 0 1 40 80a48.33 48.33 0 0 1 41.92-47.94a8 8 0 0 1 8.3 4.8l21.13 47.2a8 8 0 0 1-.66 7.53L89.32 117a7.93 7.93 0 0 0-.54 7.81c8.27 16.93 25.77 34.22 42.75 42.41a7.92 7.92 0 0 0 7.83-.59l25-21.3a8 8 0 0 1 7.59-.69l47.16 21.13a8 8 0 0 1 4.83 8.31" opacity=".2"/><path d="m222.37 158.46l-47.11-21.11l-.13-.06a16 16 0 0 0-15.17 1.4a8 8 0 0 0-.75.56L134.87 160c-15.42-7.49-31.34-23.29-38.83-38.51l20.78-24.71c.2-.25.39-.5.57-.77a16 16 0 0 0 1.32-15.06v-.12L97.54 33.64a16 16 0 0 0-16.62-9.52A56.26 56.26 0 0 0 32 80c0 79.4 64.6 144 144 144a56.26 56.26 0 0 0 55.88-48.92a16 16 0 0 0-9.51-16.62M176 208A128.14 128.14 0 0 1 48 80a40.2 40.2 0 0 1 34.87-40a.6.6 0 0 0 0 .12l21 47l-20.67 24.74a6 6 0 0 0-.57.77a16 16 0 0 0-1 15.7c9.06 18.53 27.73 37.06 46.46 46.11a16 16 0 0 0 15.75-1.14a8 8 0 0 0 .74-.56L168.89 152l47 21.05h.11A40.21 40.21 0 0 1 176 208"/></g></svg></span>
<span class="fc__meta"><span class="fc__k">電話</span><span class="fc__v">010-1234-5678</span></span>
</li>
<li class="fc__row">
<span class="fc__ic"><svg viewBox="0 0 256 256" width="15" height="15" 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="M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24m88 104a87.6 87.6 0 0 1-3.33 24h-38.51a157.4 157.4 0 0 0 0-48h38.51a87.6 87.6 0 0 1 3.33 24m-114 40h52a115.1 115.1 0 0 1-26 45a115.3 115.3 0 0 1-26-45m-3.9-16a140.8 140.8 0 0 1 0-48h59.88a140.8 140.8 0 0 1 0 48ZM40 128a87.6 87.6 0 0 1 3.33-24h38.51a157.4 157.4 0 0 0 0 48H43.33A87.6 87.6 0 0 1 40 128m114-40h-52a115.1 115.1 0 0 1 26-45a115.3 115.3 0 0 1 26 45m52.33 0h-35.62a135.3 135.3 0 0 0-22.3-45.6A88.29 88.29 0 0 1 206.37 88Zm-98.74-45.6A135.3 135.3 0 0 0 85.29 88H49.63a88.29 88.29 0 0 1 57.96-45.6M49.63 168h35.66a135.3 135.3 0 0 0 22.3 45.6A88.29 88.29 0 0 1 49.63 168m98.78 45.6a135.3 135.3 0 0 0 22.3-45.6h35.66a88.29 88.29 0 0 1-57.96 45.6"/></g></svg></span>
<span class="fc__meta"><span class="fc__k">ウェブサイト</span><span class="fc__v">seaoh.dev</span></span>
</li>
</ul>
<button class="fc__btn fc__btn--ghost" type="button" id="fc-unflip" aria-pressed="false" tabindex="-1">
<svg viewBox="0 0 256 256" width="14" height="14" 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>表面を見る
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // rotateY 플립 한 번의 시간
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.fc {
position: relative;
width: 300px;
height: 212px;
perspective: 900px;
}
.fc__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.fc__inner.is-back { transform: rotateY(180deg); }
// 양면 모두 backface-visibility hidden — 빠지면 뒷면 글자가 비쳐 보인다
.fc__face {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: $sp-4;
border-radius: $r-card;
box-shadow: $shadow-raised;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.fc__face--front { background: #fff; color: $subject-ink; }
.fc__face--back {
background: $subject-cream;
color: $subject-ink;
transform: rotateY(180deg);
justify-content: center;
gap: $sp-4;
}
.fc__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-bottom: $sp-2;
border-radius: 50%;
background: linear-gradient(135deg, $subject-blue, $subject-lilac);
color: $subject-ink;
font-weight: 800;
font-size: 17px;
line-height: 20px;
}
.fc__name { margin: 0; font-weight: 800; font-size: 17px; line-height: 22px; color: $subject-ink; }
.fc__role { margin: 0 0 $sp-2; font-weight: 600; font-size: 12px; line-height: 16px; color: rgba(23, 20, 26, .6); }
.fc__stats { margin: 0; font-weight: 600; font-size: 13px; line-height: 18px; color: rgba(23, 20, 26, .72); }
.fc__num { font-weight: 800; font-variant-numeric: tabular-nums; color: $subject-ink; }
.fc__actions { display: flex; gap: $sp-2; margin-top: auto; }
.fc__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-1;
height: 36px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-control;
font-weight: 700;
font-size: 13px;
line-height: 16px;
cursor: pointer;
touch-action: manipulation;
transition: background $dur-quick $easing, color $dur-quick $easing;
}
.fc__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.fc__btn--follow { background: $color; color: #fff; }
.fc__btn--follow.is-on { background: rgba(23, 20, 26, .08); color: $subject-ink; }
.fc__btn--ghost {
background: transparent;
color: $subject-ink;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, .3);
}
.fc__f-off,
.fc__f-on { display: inline-flex; align-items: center; gap: $sp-1; }
.fc__f-on { display: none; }
.fc__btn--follow.is-on .fc__f-off { display: none; }
.fc__btn--follow.is-on .fc__f-on { display: inline-flex; }
.fc__list {
display: flex;
flex-direction: column;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
.fc__row { display: flex; align-items: center; gap: $sp-2; text-align: left; }
.fc__ic {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 28px;
height: 28px;
border-radius: $r-sm;
background: rgba($color, .16);
color: $color;
}
.fc__meta { display: flex; flex-direction: column; }
.fc__k { font-weight: 600; font-size: 10px; line-height: 12px; color: rgba(23, 20, 26, .55); }
.fc__v { font-weight: 700; font-size: 13px; line-height: 16px; color: $subject-ink; font-variant-numeric: tabular-nums; }
// ── 자동 루프(격자 미리보기 전용) ──
// ⚠ 여기의 steps(1, end) 컷은 미리보기 루프에만 쓴다. 실제 클릭(연락처 보기 / 앞면 보기)은
// 위 .fc__inner 의 `transition: transform $duration $easing` 그대로 부드럽게 뒤집힌다.
// 회전은 |cos θ| 곡선이라 90° 부근에서 프레임당 변화가 언제나 최대다 — 등속·이징 어느 쪽으로도
// "주황 배경에 흰 선 하나" 인 모서리뷰가 poster 로 뽑히는 걸 못 막는다 (실측 2026-09-12).
// 컷으로 갈아끼우면 모서리뷰 프레임이 0장이고 앞면·뒷면의 완성 장면만 남는다.
// 앞면 0~30% · 뒷면 30~76% · 앞면 76~100% (복귀 76% ✓).
.fc.is-demo .fc__inner {
animation-name: fc-flip;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fc-flip {
0% { transform: rotateY(0deg); }
30% { transform: rotateY(180deg); }
76% { transform: rotateY(0deg); }
100% { transform: rotateY(0deg); }
}
// 컷만으로는 움직인 프레임이 2장뿐이라 양면 안쪽에 진짜 움직임을 더한다.
// 둘 다 transform 만 쓰고 페이드가 없어 반투명 중간 프레임이 생기지 않는다.
// 뒤집히는 두 프레임(30%·76%)에서는 모두 제자리 — 대표 이미지가 항상 "정돈된 면" 이 된다.
.fc.is-demo .fc__avatar {
animation-name: fc-pop;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
@keyframes fc-pop {
0% { transform: scale(1); }
6% { transform: scale(1.09); }
22% { transform: scale(1); }
80% { transform: scale(1); }
86% { transform: scale(1.09); }
96% { transform: scale(1); }
100% { transform: scale(1); }
}
.fc.is-demo .fc__ic {
animation-name: fc-tap;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__row:nth-child(2) .fc__ic { animation-delay: 83ms; }
.fc.is-demo .fc__row:nth-child(3) .fc__ic { animation-delay: 167ms; }
@keyframes fc-tap {
0% { transform: scale(1); }
36% { transform: scale(1); }
42% { transform: scale(1.22); }
58% { transform: scale(1); }
100% { transform: scale(1); }
}
@media (max-width: 340px) {
.fc { width: 272px; height: 164px; }
.fc__face { padding: $sp-3; }
.fc__face--back { gap: $sp-3; }
.fc__avatar { width: 44px; height: 44px; margin-bottom: $sp-1; font-size: 14px; line-height: 17px; }
.fc__name { font-size: 15px; line-height: 18px; }
.fc__role { margin-bottom: $sp-1; font-size: 11px; line-height: 14px; }
.fc__stats { font-size: 12px; line-height: 16px; }
.fc__btn { height: 32px; padding: 0 $sp-2; font-size: 12px; line-height: 14px; }
.fc__btn svg { width: 13px; height: 13px; }
.fc__list { gap: $sp-1; }
.fc__ic { width: 24px; height: 24px; }
.fc__ic svg { width: 13px; height: 13px; }
.fc__k { font-size: 9px; line-height: 11px; }
.fc__v { font-size: 12px; line-height: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.fc.is-demo .fc__inner,
.fc.is-demo .fc__avatar,
.fc.is-demo .fc__ic { animation: none !important; }
.fc__inner { transition: none; }
.fc__btn { transition: 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);
}
.fc {
position: relative;
width: 300px;
height: 212px;
perspective: 900px;
}
.fc__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fc__inner.is-back {
transform: rotateY(180deg);
}
.fc__face {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 16px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.fc__face--front {
background: #fff;
color: #17141a;
}
.fc__face--back {
background: #fff7e6;
color: #17141a;
transform: rotateY(180deg);
justify-content: center;
gap: 16px;
}
.fc__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-bottom: 8px;
border-radius: 50%;
background: linear-gradient(135deg, #2f6df6, #b9a0ff);
color: #17141a;
font-weight: 800;
font-size: 17px;
line-height: 20px;
}
.fc__name {
margin: 0;
font-weight: 800;
font-size: 17px;
line-height: 22px;
color: #17141a;
}
.fc__role {
margin: 0 0 8px;
font-weight: 600;
font-size: 12px;
line-height: 16px;
color: rgba(23, 20, 26, 0.6);
}
.fc__stats {
margin: 0;
font-weight: 600;
font-size: 13px;
line-height: 18px;
color: rgba(23, 20, 26, 0.72);
}
.fc__num {
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.fc__actions {
display: flex;
gap: 8px;
margin-top: auto;
}
.fc__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 12px;
border: 0;
border-radius: 12px;
font-weight: 700;
font-size: 13px;
line-height: 16px;
cursor: pointer;
touch-action: manipulation;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fc__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.fc__btn--follow {
background: #2f6df6;
color: #fff;
}
.fc__btn--follow.is-on {
background: rgba(23, 20, 26, 0.08);
color: #17141a;
}
.fc__btn--ghost {
background: transparent;
color: #17141a;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.3);
}
.fc__f-off,
.fc__f-on {
display: inline-flex;
align-items: center;
gap: 4px;
}
.fc__f-on {
display: none;
}
.fc__btn--follow.is-on .fc__f-off {
display: none;
}
.fc__btn--follow.is-on .fc__f-on {
display: inline-flex;
}
.fc__list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.fc__row {
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.fc__ic {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.16);
color: #2f6df6;
}
.fc__meta {
display: flex;
flex-direction: column;
}
.fc__k {
font-weight: 600;
font-size: 10px;
line-height: 12px;
color: rgba(23, 20, 26, 0.55);
}
.fc__v {
font-weight: 700;
font-size: 13px;
line-height: 16px;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.fc.is-demo .fc__inner {
animation-name: fc-flip;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fc-flip {
0% {
transform: rotateY(0deg);
}
30% {
transform: rotateY(180deg);
}
76% {
transform: rotateY(0deg);
}
100% {
transform: rotateY(0deg);
}
}
.fc.is-demo .fc__avatar {
animation-name: fc-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes fc-pop {
0% {
transform: scale(1);
}
6% {
transform: scale(1.09);
}
22% {
transform: scale(1);
}
80% {
transform: scale(1);
}
86% {
transform: scale(1.09);
}
96% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.fc.is-demo .fc__ic {
animation-name: fc-tap;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fc.is-demo .fc__row:nth-child(2) .fc__ic {
animation-delay: 83ms;
}
.fc.is-demo .fc__row:nth-child(3) .fc__ic {
animation-delay: 167ms;
}
@keyframes fc-tap {
0% {
transform: scale(1);
}
36% {
transform: scale(1);
}
42% {
transform: scale(1.22);
}
58% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.fc {
width: 272px;
height: 164px;
}
.fc__face {
padding: 12px;
}
.fc__face--back {
gap: 12px;
}
.fc__avatar {
width: 44px;
height: 44px;
margin-bottom: 4px;
font-size: 14px;
line-height: 17px;
}
.fc__name {
font-size: 15px;
line-height: 18px;
}
.fc__role {
margin-bottom: 4px;
font-size: 11px;
line-height: 14px;
}
.fc__stats {
font-size: 12px;
line-height: 16px;
}
.fc__btn {
height: 32px;
padding: 0 8px;
font-size: 12px;
line-height: 14px;
}
.fc__btn svg {
width: 13px;
height: 13px;
}
.fc__list {
gap: 4px;
}
.fc__ic {
width: 24px;
height: 24px;
}
.fc__ic svg {
width: 13px;
height: 13px;
}
.fc__k {
font-size: 9px;
line-height: 11px;
}
.fc__v {
font-size: 12px;
line-height: 15px;
}
}
@media (prefers-reduced-motion: reduce) {
.fc.is-demo .fc__inner,
.fc.is-demo .fc__avatar,
.fc.is-demo .fc__ic {
animation: none !important;
}
.fc__inner {
transition: none;
}
.fc__btn {
transition: none;
}
}// 실제 동작: 連絡先を見る/앞면 보기가 rotateY 플립을 토글하고, 안 보이는 면은 키보드 초점이 가지 않게 막는다.
var root = document.querySelector('.fc');
var inner = document.getElementById('fc-inner');
var front = document.getElementById('fc-front');
var back = document.getElementById('fc-back');
var flip = document.getElementById('fc-flip');
var unflip = document.getElementById('fc-unflip');
var follow = document.getElementById('fc-follow');
function setBack(on) {
inner.classList.toggle('is-back', on);
flip.setAttribute('aria-pressed', String(on));
unflip.setAttribute('aria-pressed', String(!on));
front.setAttribute('aria-hidden', on ? 'true' : 'false');
back.setAttribute('aria-hidden', on ? 'false' : 'true');
front.querySelectorAll('button').forEach(function (b) {
if (on) { b.setAttribute('tabindex', '-1'); } else { b.removeAttribute('tabindex'); }
});
back.querySelectorAll('button').forEach(function (b) {
if (on) { b.removeAttribute('tabindex'); } else { b.setAttribute('tabindex', '-1'); }
});
}
flip.addEventListener('click', function () { setBack(true); flip.blur(); });
unflip.addEventListener('click', function () { setBack(false); unflip.blur(); });
follow.addEventListener('click', function () {
var on = follow.getAttribute('aria-pressed') !== 'true';
follow.setAttribute('aria-pressed', String(on));
follow.classList.toggle('is-on', on);
});
['pointerdown', 'click', 'keydown'].forEach(function (ev) {
root.addEventListener(ev, function () { root.classList.remove('is-demo'); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FlipContactCard.module.scss";
export type FlipContactCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconUserPlus = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
const IconFlip = () => (
<svg viewBox="0 0 256 256" width="14" height="14" 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>
);
const IconMail = () => (
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><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>
);
const IconPhone = () => (
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M223.94 174.08A48.33 48.33 0 0 1 176 216A136 136 0 0 1 40 80a48.33 48.33 0 0 1 41.92-47.94a8 8 0 0 1 8.3 4.8l21.13 47.2a8 8 0 0 1-.66 7.53L89.32 117a7.93 7.93 0 0 0-.54 7.81c8.27 16.93 25.77 34.22 42.75 42.41a7.92 7.92 0 0 0 7.83-.59l25-21.3a8 8 0 0 1 7.59-.69l47.16 21.13a8 8 0 0 1 4.83 8.31" opacity=".2"/><path d="m222.37 158.46l-47.11-21.11l-.13-.06a16 16 0 0 0-15.17 1.4a8 8 0 0 0-.75.56L134.87 160c-15.42-7.49-31.34-23.29-38.83-38.51l20.78-24.71c.2-.25.39-.5.57-.77a16 16 0 0 0 1.32-15.06v-.12L97.54 33.64a16 16 0 0 0-16.62-9.52A56.26 56.26 0 0 0 32 80c0 79.4 64.6 144 144 144a56.26 56.26 0 0 0 55.88-48.92a16 16 0 0 0-9.51-16.62M176 208A128.14 128.14 0 0 1 48 80a40.2 40.2 0 0 1 34.87-40a.6.6 0 0 0 0 .12l21 47l-20.67 24.74a6 6 0 0 0-.57.77a16 16 0 0 0-1 15.7c9.06 18.53 27.73 37.06 46.46 46.11a16 16 0 0 0 15.75-1.14a8 8 0 0 0 .74-.56L168.89 152l47 21.05h.11A40.21 40.21 0 0 1 176 208"/></g></svg>
);
const IconGlobe = () => (
<svg viewBox="0 0 256 256" width="15" height="15" 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="M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24m88 104a87.6 87.6 0 0 1-3.33 24h-38.51a157.4 157.4 0 0 0 0-48h38.51a87.6 87.6 0 0 1 3.33 24m-114 40h52a115.1 115.1 0 0 1-26 45a115.3 115.3 0 0 1-26-45m-3.9-16a140.8 140.8 0 0 1 0-48h59.88a140.8 140.8 0 0 1 0 48ZM40 128a87.6 87.6 0 0 1 3.33-24h38.51a157.4 157.4 0 0 0 0 48H43.33A87.6 87.6 0 0 1 40 128m114-40h-52a115.1 115.1 0 0 1 26-45a115.3 115.3 0 0 1 26 45m52.33 0h-35.62a135.3 135.3 0 0 0-22.3-45.6A88.29 88.29 0 0 1 206.37 88Zm-98.74-45.6A135.3 135.3 0 0 0 85.29 88H49.63a88.29 88.29 0 0 1 57.96-45.6M49.63 168h35.66a135.3 135.3 0 0 0 22.3 45.6A88.29 88.29 0 0 1 49.63 168m98.78 45.6a135.3 135.3 0 0 0 22.3-45.6h35.66a88.29 88.29 0 0 1-57.96 45.6"/></g></svg>
);
export default function FlipContactCard({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: FlipContactCardProps) {
const [back, setBack] = useState(false);
const [following, setFollowing] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const followClass = following
? `${styles.btn} ${styles.btnFollow} ${styles.isOn}`
: `${styles.btn} ${styles.btnFollow}`;
return (
<div className={styles.fc} style={vars}>
<div className={back ? `${styles.inner} ${styles.isBack}` : styles.inner}>
<div className={`${styles.face} ${styles.faceFront}`} aria-hidden={back ? "true" : undefined}>
<span className={styles.avatar} aria-hidden="true">SA</span>
<p className={styles.name}>오세아</p>
<p className={styles.role}>UX 리서처</p>
<p className={styles.stats}>
<span className={styles.num}>1,204</span> 팔로워 · <span className={styles.num}>62</span> 게시물
</p>
<div className={styles.actions}>
<button
type="button"
className={followClass}
aria-pressed={following}
tabIndex={back ? -1 : undefined}
onClick={(): void => setFollowing(!following)}
>
<span className={styles.fOff}><IconUserPlus />팔로우</span>
<span className={styles.fOn}><IconCheck />팔로잉</span>
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
aria-pressed={back}
tabIndex={back ? -1 : undefined}
onClick={(): void => setBack(true)}
>
<IconFlip />연락처 보기
</button>
</div>
</div>
<div className={`${styles.face} ${styles.faceBack}`} aria-hidden={back ? undefined : "true"}>
<ul className={styles.list}>
<li className={styles.row}>
<span className={styles.ic}><IconMail /></span>
<span className={styles.meta}><span className={styles.k}>이메일</span><span className={styles.v}>[email protected]</span></span>
</li>
<li className={styles.row}>
<span className={styles.ic}><IconPhone /></span>
<span className={styles.meta}><span className={styles.k}>전화</span><span className={styles.v}>010-1234-5678</span></span>
</li>
<li className={styles.row}>
<span className={styles.ic}><IconGlobe /></span>
<span className={styles.meta}><span className={styles.k}>웹사이트</span><span className={styles.v}>seaoh.dev</span></span>
</li>
</ul>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
aria-pressed={!back}
tabIndex={back ? undefined : -1}
onClick={(): void => setBack(false)}
>
<IconFlip />앞면 보기
</button>
</div>
</div>
</div>
);
}07 · 3Dフリップ連絡先 — 計測 累積面積 43.991% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
両面とも自分の裏側を隠さないと、反対の面の文字が鏡のように透けて見えます。
.fc { perspective: 900px; width: 300px; height: 212px; }
.fc__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.fc__inner.is-back { transform: rotateY(180deg); }
.fc__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.fc__face--back { transform: rotateY(180deg); }
08ソーシャル数値カウントアップ
画面で起きること
3つの数値が0から実際の値までフレームごとに上がります。

<main class="stage">
<div class="pc is-demo">
<div class="pc__card">
<div class="pc__head">
<span class="pc__avatar" aria-hidden="true">TO</span>
<span class="pc__id">
<span class="pc__name">ユン・テオ</span>
<span class="pc__role">データアナリスト</span>
</span>
</div>
<div class="pc__stats" aria-live="polite">
<div class="pc__stat">
<span class="pc__num" id="pc-n0">0</span>
<span class="pc__label">フォロワー</span>
<span class="pc__bar"><i aria-hidden="true"></i></span>
</div>
<div class="pc__stat">
<span class="pc__num" id="pc-n1">0</span>
<span class="pc__label">投稿</span>
<span class="pc__bar"><i aria-hidden="true"></i></span>
</div>
<div class="pc__stat">
<span class="pc__num" id="pc-n2">0</span>
<span class="pc__label">いいね</span>
<span class="pc__bar"><i aria-hidden="true"></i></span>
</div>
</div>
<button class="pc__btn" type="button" id="pc-follow" aria-pressed="false">
<span class="pc__f-off"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>フォロー</span>
<span class="pc__f-on"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>フォロー中</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 팔로우 버튼 색 전환
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.pc { display: flex; justify-content: center; }
.pc__card {
box-sizing: border-box;
width: 300px;
padding: $sp-4;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
color: $subject-ink;
}
.pc__head { display: flex; align-items: center; gap: $sp-3; }
.pc__avatar {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, $subject-blue, $subject-mint);
color: $subject-ink;
font-weight: 800;
font-size: 17px;
line-height: 20px;
}
.pc__id { display: flex; flex-direction: column; }
.pc__name { font-weight: 800; font-size: 17px; line-height: 22px; color: $subject-ink; }
.pc__role { font-weight: 600; font-size: 12px; line-height: 16px; color: rgba(23, 20, 26, .6); }
.pc__stats { display: flex; gap: $sp-2; margin: $sp-3 0; }
.pc__stat {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 0;
gap: $sp-1;
}
.pc__num {
font-weight: 800;
font-size: 20px;
line-height: 24px;
color: $subject-ink;
font-variant-numeric: tabular-nums; // 자릿수가 바뀌어도 칸이 안 흔들린다
}
.pc__label { font-weight: 600; font-size: 11px; line-height: 14px; color: rgba(23, 20, 26, .6); }
.pc__bar {
display: block;
height: 4px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
overflow: hidden;
}
.pc__bar i {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
background: $color;
transform-origin: left center; // 규칙8: 스탯막대 left 채움
transform: scaleX(1);
}
.pc__btn {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-1;
width: 100%;
height: 36px;
border: 0;
border-radius: $r-control;
background: $color;
color: #fff;
font-weight: 700;
font-size: 13px;
line-height: 16px;
cursor: pointer;
touch-action: manipulation;
transition: background $duration $easing, color $duration $easing;
}
.pc__btn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.pc__btn.is-on { background: rgba(23, 20, 26, .08); color: $subject-ink; }
.pc__f-off,
.pc__f-on { display: inline-flex; align-items: center; gap: $sp-1; }
.pc__f-on { display: none; }
.pc__btn.is-on .pc__f-off { display: none; }
.pc__btn.is-on .pc__f-on { display: inline-flex; }
// ── 자동 루프(측정용): requestAnimationFrame 카운트업은 CSS 시계가 아니라 측정에 안 잡히므로
// 스탯 칸이 차례로 떠오르고 진행 막대가 차오르는 격자 움직임을 CSS 로 따로 둔다.
// 시차는 animation: 단축이 delay 를 리셋하므로 낱개 속성 + animation-delay 로 준다. ──
// ⚠ 시차 rise 를 opacity 로 부드럽게 하면 poster 에 "반투명 유령" 칸이 찍힌다 (실측 2026-09-12).
// 그래서 칸의 등장·퇴장은 steps(1, end) 한 프레임 컷이고, 움직인 프레임은 얇은 진행 막대가 채운다.
// 막대 3개는 세 번째 칸이 켜지는 바로 그 프레임에 함께 나타난다 — 그 프레임이 언제나 최대 변화라
// "세 칸이 다 보이는 상태" 가 poster 로 뽑힌다. 퇴장은 막대 → 칸 순서로 나눠 그 값을 못 넘게 한다.
// 지연이 걸린 애니메이션에는 animation-fill-mode: backwards 가 꼭 필요하다 —
// 없으면 지연 구간에 기본 상태(보임)가 그대로 보여 첫 프레임이 번쩍인다.
.pc.is-demo .pc__stat {
animation-name: pc-in;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pc.is-demo .pc__stat:nth-child(2) { animation-delay: 83ms; }
.pc.is-demo .pc__stat:nth-child(3) { animation-delay: 167ms; }
.pc.is-demo .pc__bar {
animation-name: pc-track;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pc.is-demo .pc__bar i {
animation-name: pc-fill;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes pc-in {
0% { opacity: 0; }
11% { opacity: 1; }
85% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes pc-track {
0% { opacity: 0; }
19% { opacity: 1; }
79% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes pc-fill {
0% { transform: scaleX(.42); }
19% { transform: scaleX(.42); }
62% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
@media (max-width: 340px) {
.pc__card { width: 272px; padding: $sp-3; }
.pc__head { gap: $sp-2; }
.pc__avatar { width: 44px; height: 44px; font-size: 14px; line-height: 17px; }
.pc__name { font-size: 15px; line-height: 18px; }
.pc__role { font-size: 11px; line-height: 14px; }
.pc__stats { margin: $sp-2 0; gap: $sp-1; }
.pc__num { font-size: 17px; line-height: 20px; }
.pc__label { font-size: 10px; line-height: 12px; }
.pc__bar { height: 3px; }
.pc__btn { height: 32px; font-size: 12px; line-height: 14px; }
.pc__btn svg { width: 13px; height: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.pc.is-demo .pc__stat,
.pc.is-demo .pc__bar,
.pc.is-demo .pc__bar i { animation: none !important; }
.pc__btn { transition: none; }
}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);
}
.pc {
display: flex;
justify-content: center;
}
.pc__card {
box-sizing: border-box;
width: 300px;
padding: 16px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
color: #17141a;
}
.pc__head {
display: flex;
align-items: center;
gap: 12px;
}
.pc__avatar {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #2f6df6, #4cd4a6);
color: #17141a;
font-weight: 800;
font-size: 17px;
line-height: 20px;
}
.pc__id {
display: flex;
flex-direction: column;
}
.pc__name {
font-weight: 800;
font-size: 17px;
line-height: 22px;
color: #17141a;
}
.pc__role {
font-weight: 600;
font-size: 12px;
line-height: 16px;
color: rgba(23, 20, 26, 0.6);
}
.pc__stats {
display: flex;
gap: 8px;
margin: 12px 0;
}
.pc__stat {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 0;
gap: 4px;
}
.pc__num {
font-weight: 800;
font-size: 20px;
line-height: 24px;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.pc__label {
font-weight: 600;
font-size: 11px;
line-height: 14px;
color: rgba(23, 20, 26, 0.6);
}
.pc__bar {
display: block;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
overflow: hidden;
}
.pc__bar i {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(1);
}
.pc__btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
height: 36px;
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #fff;
font-weight: 700;
font-size: 13px;
line-height: 16px;
cursor: pointer;
touch-action: manipulation;
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pc__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.pc__btn.is-on {
background: rgba(23, 20, 26, 0.08);
color: #17141a;
}
.pc__f-off,
.pc__f-on {
display: inline-flex;
align-items: center;
gap: 4px;
}
.pc__f-on {
display: none;
}
.pc__btn.is-on .pc__f-off {
display: none;
}
.pc__btn.is-on .pc__f-on {
display: inline-flex;
}
.pc.is-demo .pc__stat {
animation-name: pc-in;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pc.is-demo .pc__stat:nth-child(2) {
animation-delay: 83ms;
}
.pc.is-demo .pc__stat:nth-child(3) {
animation-delay: 167ms;
}
.pc.is-demo .pc__bar {
animation-name: pc-track;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pc.is-demo .pc__bar i {
animation-name: pc-fill;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes pc-in {
0% {
opacity: 0;
}
11% {
opacity: 1;
}
85% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes pc-track {
0% {
opacity: 0;
}
19% {
opacity: 1;
}
79% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes pc-fill {
0% {
transform: scaleX(0.42);
}
19% {
transform: scaleX(0.42);
}
62% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
@media (max-width: 340px) {
.pc__card {
width: 272px;
padding: 12px;
}
.pc__head {
gap: 8px;
}
.pc__avatar {
width: 44px;
height: 44px;
font-size: 14px;
line-height: 17px;
}
.pc__name {
font-size: 15px;
line-height: 18px;
}
.pc__role {
font-size: 11px;
line-height: 14px;
}
.pc__stats {
margin: 8px 0;
gap: 4px;
}
.pc__num {
font-size: 17px;
line-height: 20px;
}
.pc__label {
font-size: 10px;
line-height: 12px;
}
.pc__bar {
height: 3px;
}
.pc__btn {
height: 32px;
font-size: 12px;
line-height: 14px;
}
.pc__btn svg {
width: 13px;
height: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc.is-demo .pc__stat,
.pc.is-demo .pc__bar,
.pc.is-demo .pc__bar i {
animation: none !important;
}
.pc__btn {
transition: none;
}
}// 실제 동작: requestAnimationFrame 카운트업(로케일 의존 없는 3자리 콤마).
// 팔로우를 누르면 フォロワー 값이 1 늘고 그 숫자만 다시 카운트업된다.
var root = document.querySelector('.pc');
var nums = [0, 1, 2].map(function (i) { return document.getElementById('pc-n' + i); });
var targets = [12480, 316, 48920];
var current = [0, 0, 0];
var runs = [0, 0, 0];
function commas(n) {
var s = String(Math.round(n));
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function countTo(i, to, ms) {
var run = ++runs[i];
// 애니메이션 축소를 켠 사람에게는 숫자를 세지 않고 최종값을 바로 넣는다 (React 판과 같은 가드)
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
current[i] = to;
nums[i].textContent = commas(to);
return;
}
var from = current[i], t0 = performance.now();
function frame(now) {
if (runs[i] !== run) return;
var p = Math.min(1, (now - t0) / ms);
current[i] = Math.round(from + (to - from) * easeOutCubic(p));
nums[i].textContent = commas(current[i]);
if (p < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
nums.forEach(function (_, i) { countTo(i, targets[i], 1400); });
var follow = document.getElementById('pc-follow');
follow.addEventListener('click', function () {
var on = follow.getAttribute('aria-pressed') !== 'true';
follow.setAttribute('aria-pressed', String(on));
follow.classList.toggle('is-on', on);
targets[0] += on ? 1 : -1;
countTo(0, targets[0], 600);
});
['pointerdown', 'click', 'keydown'].forEach(function (ev) {
root.addEventListener(ev, function () { root.classList.remove('is-demo'); });
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StatCountUpCard.module.scss";
export type StatCountUpCardProps = {
duration?: number;
easing?: string;
color?: string;
countMs?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Triple = [number, number, number];
// useRef 는 항상 `T | null` 을 돌려주므로 값 보관용 상자는 널이 아닌 타입으로 좁혀 쓴다
type Box<T> = { current: T };
const BASE: Triple = [12480, 316, 48920];
function commas(n: number): string {
return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
const IconUserPlus = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function StatCountUpCard({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
countMs = 1400,
}: StatCountUpCardProps) {
const [shown, setShown] = useState<Triple>([0, 0, 0]);
const [following, setFollowing] = useState(false);
const shownRef = useRef<Triple>([0, 0, 0]) as Box<Triple>;
const targetsRef = useRef<Triple>([...BASE] as Triple) as Box<Triple>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function count(to: Triple, ms: number): void {
cancelAnimationFrame(rafRef.current);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) {
const done: Triple = [...to] as Triple;
shownRef.current = done;
setShown(done);
return;
}
const from: Triple = [...shownRef.current] as Triple;
const t0 = performance.now();
const step = (now: number): void => {
const p = Math.min(1, (now - t0) / ms);
const next = [0, 0, 0] as Triple;
for (let i = 0; i < 3; i += 1) {
next[i] = Math.round(from[i] + (to[i] - from[i]) * easeOutCubic(p));
}
shownRef.current = next;
setShown(next);
if (p < 1) rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
}
useEffect((): (() => void) => {
count([...BASE] as Triple, countMs);
return (): void => cancelAnimationFrame(rafRef.current);
// 마운트 시 1회 — 값은 BASE 로 고정
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function onFollow(): void {
const next = !following;
setFollowing(next);
targetsRef.current = [targetsRef.current[0] + (next ? 1 : -1), targetsRef.current[1], targetsRef.current[2]];
count(targetsRef.current, 600);
}
return (
<div className={styles.pc} style={vars}>
<div className={styles.card}>
<div className={styles.head}>
<span className={styles.avatar} aria-hidden="true">TO</span>
<span className={styles.id}>
<span className={styles.name}>윤태오</span>
<span className={styles.role}>데이터 분석가</span>
</span>
</div>
<div className={styles.stats} aria-live="polite">
<div className={styles.stat}>
<span className={styles.num}>{commas(shown[0])}</span>
<span className={styles.label}>팔로워</span>
<span className={styles.bar}><i aria-hidden="true" /></span>
</div>
<div className={styles.stat}>
<span className={styles.num}>{commas(shown[1])}</span>
<span className={styles.label}>게시물</span>
<span className={styles.bar}><i aria-hidden="true" /></span>
</div>
<div className={styles.stat}>
<span className={styles.num}>{commas(shown[2])}</span>
<span className={styles.label}>좋아요</span>
<span className={styles.bar}><i aria-hidden="true" /></span>
</div>
</div>
<button
type="button"
className={following ? `${styles.btn} ${styles.isOn}` : styles.btn}
aria-pressed={following}
onClick={onFollow}
>
<span className={styles.fOff}><IconUserPlus />팔로우</span>
<span className={styles.fOn}><IconCheck />팔로잉</span>
</button>
</div>
</div>
);
}08 · ソーシャル数値カウントアップ — 計測 累積面積 2.694% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
フォローを押すとフォロワーの目標値が1増えてその数字だけ数え直し、古いカウントが上書きし続けないよう実行ごとに番号を振ります。
function countTo(i, to, ms) {
var run = ++runs[i];
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
current[i] = to;
nums[i].textContent = commas(to);
return;
}
var from = current[i], t0 = performance.now();
function frame(now) {
if (runs[i] !== run) return;
var p = Math.min(1, (now - t0) / ms);
current[i] = Math.round(from + (to - from) * easeOutCubic(p));
nums[i].textContent = commas(current[i]);
if (p < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
09チームグリッド展開
どんな動きか
タイルがひとつ広がると役職とフォローボタンが下から現れ、残りの2つが同じ時間で狭まるため、行全体の幅は変わりません。

<main class="stage">
<div class="tg is-demo">
<p class="tg__head">
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M168 144a40 40 0 1 1-40-40a40 40 0 0 1 40 40M64 56a32 32 0 1 0 32 32a32 32 0 0 0-32-32m128 0a32 32 0 1 0 32 32a32 32 0 0 0-32-32" opacity=".2"/><path d="M244.8 150.4a8 8 0 0 1-11.2-1.6A51.6 51.6 0 0 0 192 128a8 8 0 0 1 0-16a24 24 0 1 0-23.24-30a8 8 0 1 1-15.5-4A40 40 0 1 1 219 117.51a67.94 67.94 0 0 1 27.43 21.68a8 8 0 0 1-1.63 11.21M190.92 212a8 8 0 1 1-13.85 8a57 57 0 0 0-98.15 0a8 8 0 1 1-13.84-8a72.06 72.06 0 0 1 33.74-29.92a48 48 0 1 1 58.36 0A72.06 72.06 0 0 1 190.92 212M128 176a32 32 0 1 0-32-32a32 32 0 0 0 32 32m-56-56a8 8 0 0 0-8-8a24 24 0 1 1 23.24-30a8 8 0 1 0 15.5-4A40 40 0 1 0 37 117.51a67.94 67.94 0 0 0-27.4 21.68a8 8 0 1 0 12.8 9.61A51.6 51.6 0 0 1 64 128a8 8 0 0 0 8-8"/></g></svg>
チームメンバー
</p>
<div class="tg__row">
<div class="tg__tile is-open">
<button class="tg__main" type="button" aria-expanded="true">
<span class="tg__avatar tg__avatar--a" aria-hidden="true">JH</span>
<span class="tg__name">カン・ジュハ</span>
<span class="tg__role">チームリード</span>
</button>
<button class="tg__follow" type="button" aria-pressed="false">
<span class="tg__f-off"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>フォロー</span>
<span class="tg__f-on"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>フォロー中</span>
</button>
</div>
<div class="tg__tile">
<button class="tg__main" type="button" aria-expanded="false">
<span class="tg__avatar tg__avatar--b" aria-hidden="true">ES</span>
<span class="tg__name">ノ・ウンソル</span>
<span class="tg__role">プロダクトデザイナー</span>
</button>
<button class="tg__follow" type="button" aria-pressed="false">
<span class="tg__f-off"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>フォロー</span>
<span class="tg__f-on"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>フォロー中</span>
</button>
</div>
<div class="tg__tile">
<button class="tg__main" type="button" aria-expanded="false">
<span class="tg__avatar tg__avatar--c" aria-hidden="true">GO</span>
<span class="tg__name">イム・ガオン</span>
<span class="tg__role">フロントエンド開発者</span>
</button>
<button class="tg__follow" type="button" aria-pressed="false">
<span class="tg__f-off"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>フォロー</span>
<span class="tg__f-on"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>フォロー中</span>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 타일이 넓어지는 시간
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.tg { width: 320px; }
.tg__head {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0 0 $sp-3;
font-weight: 700;
font-size: 13px;
line-height: 18px;
color: $subject-cream;
}
.tg__head svg { flex: none; color: rgba(255, 255, 255, .55); }
.tg__row { display: flex; gap: $sp-2; }
.tg__tile {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 136px;
border-radius: $r-card;
background: rgba(255, 255, 255, .07);
overflow: hidden; // 접힌 타일에서 넘치는 글자는 잘린다
flex-grow: 1;
transition: flex-grow $duration $easing, background $duration $easing;
}
.tg__tile.is-open { flex-grow: 2.6; background: rgba(255, 255, 255, .15); }
.tg__main {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
padding: $sp-3 $sp-2 $sp-10;
border: 0;
background: transparent;
color: $subject-cream;
text-align: center;
cursor: pointer;
touch-action: manipulation;
}
.tg__main:focus-visible { outline: 2px solid $color; outline-offset: -2px; border-radius: $r-card; }
.tg__avatar {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 36px;
height: 36px;
border-radius: 50%;
color: $subject-ink;
font-weight: 800;
font-size: 13px;
line-height: 16px;
}
.tg__avatar--a { background: linear-gradient(135deg, $subject-blue, $subject-lilac); }
.tg__avatar--b { background: linear-gradient(135deg, $subject-pink, $subject-lilac); }
.tg__avatar--c { background: linear-gradient(135deg, $subject-mint, $subject-sky); }
.tg__name {
max-width: 100%;
font-weight: 800;
font-size: 13px;
line-height: 17px;
color: $subject-cream;
white-space: nowrap;
}
.tg__role {
max-width: 100%;
font-weight: 600;
font-size: 11px;
line-height: 14px;
color: rgba(255, 255, 255, .62);
white-space: nowrap;
opacity: 0;
transform: translateY(8px);
transition: opacity $duration $easing, transform $duration $easing;
}
.tg__tile.is-open .tg__role { opacity: 1; transform: translateY(0); }
.tg__follow {
position: absolute;
left: $sp-2;
right: $sp-2;
bottom: $sp-3;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-1;
height: 28px;
border: 0;
border-radius: $r-control;
background: $color;
color: #fff;
font-weight: 700;
font-size: 12px;
line-height: 14px;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing, background $dur-quick $easing;
}
.tg__tile.is-open .tg__follow { opacity: 1; transform: translateY(0); pointer-events: auto; }
.tg__follow:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.tg__follow.is-on { background: rgba(255, 255, 255, .18); color: $subject-cream; }
.tg__f-off,
.tg__f-on { display: inline-flex; align-items: center; gap: $sp-1; }
.tg__f-on { display: none; }
.tg__follow.is-on .tg__f-off { display: none; }
.tg__follow.is-on .tg__f-on { display: inline-flex; }
// ── 자동 루프: 1번 → 2번 → 3번 순으로 flex-grow 가 커졌다 작아진다.
// 시차는 animation: 단축이 delay 를 0 으로 리셋하므로 낱개 속성 + animation-delay 로 준다.
// 마지막 타일도 87%(1744ms) 안에 1 로 돌아오고 끝까지 그대로 → 텔레포트 없음.
// ⚠ 직함·팔로우 버튼을 opacity 로 부드럽게 띄우면 poster 에 "반투명 유령" 이 찍힌다 (실측 2026-09-12).
// 그래서 (1) 펼치기는 ease-in — 다 펼쳐지는 마지막 한 프레임이 가장 크게 변하고,
// (2) 직함·버튼은 바로 그 프레임에 steps(1, end) 로 컷 등장하며,
// (3) 되돌아오기는 linear 로 여러 프레임에 고르게 나눠 그 프레임을 넘지 않게 한다.
// → 최대 변화 프레임 = "완전히 펼쳐진 타일 + 또렷한 직함·버튼" 이 poster 가 된다. ──
.tg.is-demo .tg__tile {
animation-name: tg-grow;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tg.is-demo .tg__role,
.tg.is-demo .tg__follow {
animation-name: tg-detail;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tg.is-demo .tg__tile:nth-child(2),
.tg.is-demo .tg__tile:nth-child(2) .tg__role,
.tg.is-demo .tg__tile:nth-child(2) .tg__follow { animation-delay: 583ms; }
.tg.is-demo .tg__tile:nth-child(3),
.tg.is-demo .tg__tile:nth-child(3) .tg__role,
.tg.is-demo .tg__tile:nth-child(3) .tg__follow { animation-delay: 1166ms; }
@keyframes tg-grow {
0% { flex-grow: 1; animation-timing-function: cubic-bezier(.55, 0, .85, .25); }
12.2% { flex-grow: 2.6; }
16.4% { flex-grow: 2.6; }
28.9% { flex-grow: 1; }
100% { flex-grow: 1; }
}
@keyframes tg-detail {
0% { opacity: 0; transform: translateY(8px); }
12.2% { opacity: 1; transform: translateY(0); }
20% { opacity: 0; transform: translateY(8px); }
100% { opacity: 0; transform: translateY(8px); }
}
@media (max-width: 340px) {
.tg { width: 272px; }
.tg__head { gap: $sp-1; margin-bottom: $sp-2; font-size: 12px; line-height: 15px; }
.tg__row { gap: $sp-1; }
.tg__tile { height: 104px; }
.tg__main { gap: 0; padding: $sp-2 $sp-1 $sp-8; }
.tg__avatar { width: 28px; height: 28px; font-size: 11px; line-height: 14px; }
.tg__name { font-size: 12px; line-height: 15px; }
.tg__role { font-size: 10px; line-height: 12px; }
.tg__follow { left: $sp-1; right: $sp-1; bottom: $sp-2; height: 28px; font-size: 11px; line-height: 13px; }
.tg__follow svg { width: 12px; height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.tg.is-demo .tg__tile,
.tg.is-demo .tg__role,
.tg.is-demo .tg__follow { animation: none !important; }
.tg__tile, .tg__role, .tg__follow { transition: 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);
}
.tg {
width: 320px;
}
.tg__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 12px;
font-weight: 700;
font-size: 13px;
line-height: 18px;
color: #fff7e6;
}
.tg__head svg {
flex: none;
color: rgba(255, 255, 255, 0.55);
}
.tg__row {
display: flex;
gap: 8px;
}
.tg__tile {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 136px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.07);
overflow: hidden;
flex-grow: 1;
transition: flex-grow 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tg__tile.is-open {
flex-grow: 2.6;
background: rgba(255, 255, 255, 0.15);
}
.tg__main {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px 40px;
border: 0;
background: transparent;
color: #fff7e6;
text-align: center;
cursor: pointer;
touch-action: manipulation;
}
.tg__main:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
border-radius: 16px;
}
.tg__avatar {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 36px;
height: 36px;
border-radius: 50%;
color: #17141a;
font-weight: 800;
font-size: 13px;
line-height: 16px;
}
.tg__avatar--a {
background: linear-gradient(135deg, #2f6df6, #b9a0ff);
}
.tg__avatar--b {
background: linear-gradient(135deg, #ff9ebb, #b9a0ff);
}
.tg__avatar--c {
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
}
.tg__name {
max-width: 100%;
font-weight: 800;
font-size: 13px;
line-height: 17px;
color: #fff7e6;
white-space: nowrap;
}
.tg__role {
max-width: 100%;
font-weight: 600;
font-size: 11px;
line-height: 14px;
color: rgba(255, 255, 255, 0.62);
white-space: nowrap;
opacity: 0;
transform: translateY(8px);
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tg__tile.is-open .tg__role {
opacity: 1;
transform: translateY(0);
}
.tg__follow {
position: absolute;
left: 8px;
right: 8px;
bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 28px;
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #fff;
font-weight: 700;
font-size: 12px;
line-height: 14px;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tg__tile.is-open .tg__follow {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.tg__follow:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.tg__follow.is-on {
background: rgba(255, 255, 255, 0.18);
color: #fff7e6;
}
.tg__f-off,
.tg__f-on {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tg__f-on {
display: none;
}
.tg__follow.is-on .tg__f-off {
display: none;
}
.tg__follow.is-on .tg__f-on {
display: inline-flex;
}
.tg.is-demo .tg__tile {
animation-name: tg-grow;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tg.is-demo .tg__role,
.tg.is-demo .tg__follow {
animation-name: tg-detail;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tg.is-demo .tg__tile:nth-child(2),
.tg.is-demo .tg__tile:nth-child(2) .tg__role,
.tg.is-demo .tg__tile:nth-child(2) .tg__follow {
animation-delay: 583ms;
}
.tg.is-demo .tg__tile:nth-child(3),
.tg.is-demo .tg__tile:nth-child(3) .tg__role,
.tg.is-demo .tg__tile:nth-child(3) .tg__follow {
animation-delay: 1166ms;
}
@keyframes tg-grow {
0% {
flex-grow: 1;
animation-timing-function: cubic-bezier(0.55, 0, 0.85, 0.25);
}
12.2% {
flex-grow: 2.6;
}
16.4% {
flex-grow: 2.6;
}
28.9% {
flex-grow: 1;
}
100% {
flex-grow: 1;
}
}
@keyframes tg-detail {
0% {
opacity: 0;
transform: translateY(8px);
}
12.2% {
opacity: 1;
transform: translateY(0);
}
20% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 0;
transform: translateY(8px);
}
}
@media (max-width: 340px) {
.tg {
width: 272px;
}
.tg__head {
gap: 4px;
margin-bottom: 8px;
font-size: 12px;
line-height: 15px;
}
.tg__row {
gap: 4px;
}
.tg__tile {
height: 104px;
}
.tg__main {
gap: 0;
padding: 8px 4px 32px;
}
.tg__avatar {
width: 28px;
height: 28px;
font-size: 11px;
line-height: 14px;
}
.tg__name {
font-size: 12px;
line-height: 15px;
}
.tg__role {
font-size: 10px;
line-height: 12px;
}
.tg__follow {
left: 4px;
right: 4px;
bottom: 8px;
height: 28px;
font-size: 11px;
line-height: 13px;
}
.tg__follow svg {
width: 12px;
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.tg.is-demo .tg__tile,
.tg.is-demo .tg__role,
.tg.is-demo .tg__follow {
animation: none !important;
}
.tg__tile, .tg__role, .tg__follow {
transition: none;
}
}// 실제 동작: 호버·클릭·화살표로 타일 하나가 넓어지고(flex-grow) 그 안의 직함·팔로우가 올라온다.
var root = document.querySelector('.tg');
var tiles = [].slice.call(document.querySelectorAll('.tg__tile'));
var mains = tiles.map(function (t) { return t.querySelector('.tg__main'); });
var at = 0;
function open(i) {
at = Math.max(0, Math.min(i, tiles.length - 1));
tiles.forEach(function (t, k) {
t.classList.toggle('is-open', k === at);
mains[k].setAttribute('aria-expanded', String(k === at));
});
}
tiles.forEach(function (tile, i) {
tile.addEventListener('pointerenter', function () { root.classList.remove('is-demo'); open(i); });
mains[i].addEventListener('click', function () { root.classList.remove('is-demo'); open(i); });
mains[i].addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
root.classList.remove('is-demo');
var next = (i + (e.key === 'ArrowRight' ? 1 : tiles.length - 1)) % tiles.length;
open(next);
mains[next].focus({ preventScroll: true });
});
tile.querySelector('.tg__follow').addEventListener('click', function () {
root.classList.remove('is-demo');
var b = tile.querySelector('.tg__follow');
var on = b.getAttribute('aria-pressed') !== 'true';
b.setAttribute('aria-pressed', String(on));
b.classList.toggle('is-on', on);
});
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TeamGridCard.module.scss";
export type TeamGridCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
// useRef 는 항상 `T | null` 을 돌려주므로 목록 보관용 상자는 널이 아닌 타입으로 좁혀 쓴다
type Box<T> = { current: T };
type Member = { initials: string; name: string; role: string; tone: string };
const MEMBERS: Member[] = [
{ initials: "JH", name: "강주하", role: "팀 리드", tone: "a" },
{ initials: "ES", name: "노은설", role: "프로덕트 디자이너", tone: "b" },
{ initials: "GO", name: "임가온", role: "프론트엔드 개발자", tone: "c" },
];
const IconUsers = () => (
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M168 144a40 40 0 1 1-40-40a40 40 0 0 1 40 40M64 56a32 32 0 1 0 32 32a32 32 0 0 0-32-32m128 0a32 32 0 1 0 32 32a32 32 0 0 0-32-32" opacity=".2"/><path d="M244.8 150.4a8 8 0 0 1-11.2-1.6A51.6 51.6 0 0 0 192 128a8 8 0 0 1 0-16a24 24 0 1 0-23.24-30a8 8 0 1 1-15.5-4A40 40 0 1 1 219 117.51a67.94 67.94 0 0 1 27.43 21.68a8 8 0 0 1-1.63 11.21M190.92 212a8 8 0 1 1-13.85 8a57 57 0 0 0-98.15 0a8 8 0 1 1-13.84-8a72.06 72.06 0 0 1 33.74-29.92a48 48 0 1 1 58.36 0A72.06 72.06 0 0 1 190.92 212M128 176a32 32 0 1 0-32-32a32 32 0 0 0 32 32m-56-56a8 8 0 0 0-8-8a24 24 0 1 1 23.24-30a8 8 0 1 0 15.5-4A40 40 0 1 0 37 117.51a67.94 67.94 0 0 0-27.4 21.68a8 8 0 1 0 12.8 9.61A51.6 51.6 0 0 1 64 128a8 8 0 0 0 8-8"/></g></svg>
);
const IconUserPlus = () => (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = () => (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function TeamGridCard({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TeamGridCardProps) {
const [at, setAt] = useState(0);
const [followed, setFollowed] = useState<boolean[]>([false, false, false]);
const mainsRef = useRef<(HTMLButtonElement | null)[]>([null, null, null]) as Box<(HTMLButtonElement | null)[]>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onKey(e: KeyEvt, i: number): void {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const next = (i + (e.key === "ArrowRight" ? 1 : MEMBERS.length - 1)) % MEMBERS.length;
setAt(next);
const el = mainsRef.current[next];
if (el) el.focus({ preventScroll: true });
}
function toggleFollow(i: number): void {
setFollowed(followed.map((v: boolean, k: number): boolean => (k === i ? !v : v)));
}
return (
<div className={styles.tg} style={vars}>
<p className={styles.head}><IconUsers />팀 멤버</p>
<div className={styles.row}>
{MEMBERS.map((m: Member, i: number) => {
const open = i === at;
const tone = m.tone === "a" ? styles.toneA : m.tone === "b" ? styles.toneB : styles.toneC;
return (
<div
key={m.initials}
className={open ? `${styles.tile} ${styles.isOpen}` : styles.tile}
onPointerEnter={(): void => setAt(i)}
>
<button
type="button"
ref={(el: HTMLButtonElement | null): void => { mainsRef.current[i] = el; }}
className={styles.main}
aria-expanded={open}
onClick={(): void => setAt(i)}
onKeyDown={(e: KeyEvt): void => onKey(e, i)}
>
<span className={`${styles.avatar} ${tone}`} aria-hidden="true">{m.initials}</span>
<span className={styles.name}>{m.name}</span>
<span className={styles.role}>{m.role}</span>
</button>
<button
type="button"
className={followed[i] ? `${styles.follow} ${styles.isOn}` : styles.follow}
aria-pressed={followed[i]}
tabIndex={open ? undefined : -1}
onClick={(): void => toggleFollow(i)}
>
<span className={styles.fOff}><IconUserPlus />팔로우</span>
<span className={styles.fOn}><IconCheck />팔로잉</span>
</button>
</div>
);
})}
</div>
</div>
);
}09 · チームグリッド展開 — 計測 累積面積 22.797% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
キーボードで焦点を移すときはスクロールを止めます。
mains[i].addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
root.classList.remove('is-demo');
var next = (i + (e.key === 'ArrowRight' ? 1 : tiles.length - 1)) % tiles.length;
open(next);
mains[next].focus({ preventScroll: true });
});
どこで壊れるか — 落とし穴
今回の落とし穴はスタイルではなくサムネイルで出ました。グリッドや共有カードに使う代表画像は、2秒を24枚に切り出して「直前の1枚と最も大きく変わった1枚」を選ぶのですが、07のフリップは必ず真横から見た場面が選ばれました。オレンジの背景に白い線が1本だけ残った絵です。偶然ではありません。縦軸で回った面の画面上の幅は回転角のコサインで、その変化率はサインなので、90度付近でいつも最大になります。等速でもイージング付きでも、最も変わった1枚は真横です。
同じ症状があと3つありました。08の数値3枠と09の役職・ボタンを透明度でふんわり出したところ代表画像が半透明の幽霊になり、04の下線は等速で引いたせいで開始直後の短い棒が選ばれ、書き損じのように見えました。直し方の考え方は3つとも同じで、じわじわ変わる区間をなくし、完成した状態が1枚のうちに現れるようにしました。08と09は透明度の区間を steps(1, end) の切り替えに置き換え、その切り替えが完成した状態に重なるよう合わせています。04は透明度ではなく線の伸びなので、最後のひと区切りを1枚ぶんの間隔である83.3ミリ秒に詰め込み、線が端まで引かれる1枚が最大の変化になるようにしました。07はプレビュー用のループだけを切り替えに変え、実際のクリックはなめらかな遷移のまま残しました。切り替えにしたことで回転中に画面を掃いていた面積が消え、変化したピクセルの面積は56.2%から44.0%に下がったので、代わりに透明度を使わない小さな動きを両面に入れて、動いた枚数を10枚から18枚に戻しています。切り替えだけでは動きが2枚しかなく、アニメーションなしと測定されてしまうためです。
遅延を付けたアニメーションでもうひとつ引っかかりました。animation-delay だけ付けて animation-fill-mode: backwards を省くと、遅延の間ずっと要素は書いたままの状態で描画されます。08の数値3枠は最初の1枚に全部見えていて、消えてから順番に点き直しました。遅延の間はアニメーションがまだ適用されておらず、フレームを切り出す側はその時刻をそのまま写すからです。ずらしを animation の一括指定で書くと遅延そのものが0に戻るのも同じ系統で、03・04・06・07・08・09はすべて個別プロパティで書いています。
アクセシビリティ
色のコントラストは推測せず、合成した実際の色で計算しました。半透明の板の下では、背景と混ざったあとの色が本当の背景になるため、目には白い文字がはっきり見えても数値は合格になりません。
| 項目 | 文字 / 背景 | 比 | 判定 |
|---|---|---|---|
| 01 ガラス | 墨色 / 曇り板 (255, 130, 98) | 7.49:1 | 合格 |
| 01 ガラス(捨てた案) | 白 / 同じ曇り板 | 2.44:1 | 不足 |
| 02 ネオブルータリズム | 白 / 青のピル | 4.53:1 | 合格 |
| 03 クレー | 墨色62% / ピンクのカード | 4.79:1 | 合格 |
| 04 ミニマル | 墨色64% / 白いカード | 5.44:1 | 合格 |
| 06 ダーク | ミントの状態点 / 暗い板 | 8.64:1 | 合格 |
| 09 チーム | クリーム / 広がったタイル | 10.99:1 | 合格 |
01は当初白い文字で、2.44:1と基準に届きませんでした。曇り板をより白くして文字を墨色に変えたところ7.49:1になりました。04の補助文字も不透明度55%で4.04:1だったため64%に上げています。名刺に数字を刻む必要があればcss グラフ をSVGで描くの座標計算をそのまま借りてこられます。コントラストまで直した9組のソースは、圧縮パスワード kx4qjk9q を入れると開くzipの中にあり、バニラ版とReact版が同じ値を使っています。
prefers-reduced-motion: reduce では9つともCSSの自動再生ループを止めます。01のにじみは最初の位置のまま動かず、02は繰り返しの代わりに実際の :active だけが残り、03の呼吸はふくらむ前の大きさで止まり、05の枠は回転を止めてもグラデーションはそのまま見えます。06は脈打つ光だけが弱い側で止まり、固定の box-shadow によるにじみは残ります。07は反転の遷移が切れてクリックすると即座に裏面になり、08は数値の枠が順に現れる演出と進行バーが消え、09は広がる遷移なしで状態だけ変わります。08の数値のカウントアップだけはCSSではなくJavaScriptで書かれていてメディアクエリが自動では届かないため、バニラ版とReact版の両方で matchMedia('(prefers-reduced-motion: reduce)') を一度読み、数えずに最終値をそのまま入れています。どの場合もフォロー済みかどうか、広がっているかどうかという状態の値は残ります。07の3D関連のプロパティは MDNのtransform-styleの解説にまとまっています。
同じカード系をもっと探すなら、CSS カード ホバー 効果9選とログイン画面 デザイン 9選が近いテーマです。
FAQ
「プロフィールカード 作り方」としてアバター画像なしでイニシャルだけでも大丈夫ですか
大丈夫です。9つとも画像ファイルを使わず、CSSのグラデーションで塗った円の中にイニシャル2文字を入れています。写真がまだないアカウントでは、灰色の人型よりも空きが目立たず、ネットワークリクエストも1件も増えません。あとで写真が用意できたら円の内側を background-image で覆うだけで済み、外側の大きさや枠はそのままにできます。プロフィールカードが画面最初の部品になる配置はファーストビュー デザインでまとめて扱います。
backdrop-filter を入れたのに何もぼけません
その要素の後ろにぼける対象がないからです。backdrop-filter は要素自身ではなく後ろに敷かれたものをぼかすので、単色の背景に重ねるとぼけた単色になり、違いがまったく見えません。01のにじみや写真のように、後ろに何かが必要です。カードの背景が完全に不透明でも同じように結果が隠れるので、アルファ値は必ず1未満にします。
グラデーションの枠が回りません
@property の登録が抜けている可能性が高いです。ただ宣言しただけのカスタムプロパティはブラウザが文字列として扱うため2つの値の間を補間できず、角度が0度から360度へ一気に跳んで止まって見えます。05のように syntax: "<angle>" と initial-value を書いて登録すると、角度が実際につながって動きます。









