03ケース詳細
'결과 보기' 버튼을 누르면 패널이 실제로 펼쳐지며 전환율·제작 기간·트래픽 증가 숫자 세 개가 0에서 목표값까지 올라갑니다.
使いどころ
requestAnimationFramemax-heightclick

<main class="stage">
<section class="cd">
<span class="cd__accent" aria-hidden="true"></span>
<p class="cd__eyebrow">CASE STUDY</p>
<h2 class="cd__title">決済フローの再設計</h2>
<p class="cd__blurb">決済を5ステップから3ステップに減らし、離脱ポイントを見直しました。</p>
<button class="cd__toggle" type="button" aria-expanded="false" aria-controls="cd-panel">
結果を見る
</button>
<div class="cd__panel" id="cd-panel" role="status" aria-live="polite">
<div class="cd__stat"><p class="cd__num" data-target="38" data-suffix="%">0%</p><p class="cd__label">コンバージョン率が上昇</p></div>
<div class="cd__stat"><p class="cd__num" data-target="6" data-suffix="週間">0週間</p><p class="cd__label">制作期間</p></div>
<div class="cd__stat"><p class="cd__num" data-target="210" data-suffix="%">0%</p><p class="cd__label">トラフィック増加</p></div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 패널 펼침
$easing: $ease-pop;
$color: $subject-blue; // 링·숫자 강조색
@include stage($stage-orange);
.cd {
width: min(400px, 94vw);
border-radius: $r-card;
padding: $sp-4;
box-sizing: border-box;
background: rgba(255, 255, 255, .92);
text-align: center;
}
// ── 상단 악센트 바가 계속 스캔하는 장식(항상 재생) ──────────────
.cd__accent {
display: block;
width: 160px; height: 4px;
margin: 0 auto $sp-2;
border-radius: $r-xs;
background: $color;
transform-origin: center;
animation: cd-scan 2s $easing infinite;
}
@keyframes cd-scan {
0%, 70%, 100% { transform: scaleX(.3); opacity: .5; }
30% { transform: scaleX(1); opacity: 1; }
}
.cd__eyebrow {
margin: 0 0 $sp-1;
font-weight: 700; font-size: 10px; line-height: 1; font-family: inherit;
letter-spacing: .14em;
color: rgba($subject-ink, .72); // 페이퍼 면 위 6.92:1
}
.cd__title { margin: 0 0 $sp-1; font-weight: 800; font-size: clamp(14px, 4vw, 17px); line-height: 1.2; font-family: inherit; color: $subject-ink; }
.cd__blurb { margin: 0 0 $sp-3; font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.4; font-family: inherit; color: rgba(23, 20, 26, .6); }
.cd__toggle {
position: relative;
display: inline-flex; align-items: center; gap: $sp-1;
padding: $sp-1 $sp-4;
border-radius: $r-pill;
border: 1px solid rgba(23, 20, 26, .18);
background: none;
color: $subject-ink;
font-weight: 700; font-size: 11px; line-height: 1.6; font-family: inherit;
cursor: pointer;
}
.cd__panel {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
max-height: 0;
opacity: 0;
overflow: hidden;
box-sizing: border-box;
margin-top: 0;
transition: max-height $duration $easing, opacity $duration $easing, margin-top $duration $easing;
}
.cd__panel.is-open {
max-height: 120px;
opacity: 1;
margin-top: $sp-3;
}
.cd__num {
margin: 0;
font-weight: 800; font-size: clamp(15px, 4.4vw, 18px); line-height: 1; font-family: inherit;
color: $color;
font-variant-numeric: tabular-nums;
}
.cd__label { margin: $sp-1 0 0; font-weight: 600; font-size: 9px; line-height: 1.3; font-family: inherit; color: rgba($subject-ink, .72); } // 5.7:1 — .5 는 3.4 로 미달
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.cd { padding: $sp-3; }
.cd__blurb { display: none; }
.cd__label { display: none; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.cd__accent { animation: none !important; }
.cd__panel { 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);
}
.cd {
width: min(400px, 94vw);
border-radius: 16px;
padding: 16px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.92);
text-align: center;
}
.cd__accent {
display: block;
width: 160px;
height: 4px;
margin: 0 auto 8px;
border-radius: 4px;
background: #2f6df6;
transform-origin: center;
animation: cd-scan 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes cd-scan {
0%, 70%, 100% {
transform: scaleX(0.3);
opacity: 0.5;
}
30% {
transform: scaleX(1);
opacity: 1;
}
}
.cd__eyebrow {
margin: 0 0 4px;
font-weight: 700;
font-size: 10px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.14em;
color: rgba(23, 20, 26, 0.72);
}
.cd__title {
margin: 0 0 4px;
font-weight: 800;
font-size: clamp(14px, 4vw, 17px);
line-height: 1.2;
font-family: inherit;
color: #17141a;
}
.cd__blurb {
margin: 0 0 12px;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.cd__toggle {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 16px;
border-radius: 999px;
border: 1px solid rgba(23, 20, 26, 0.18);
background: none;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
cursor: pointer;
}
.cd__panel {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
max-height: 0;
opacity: 0;
overflow: hidden;
box-sizing: border-box;
margin-top: 0;
transition: max-height 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1), margin-top 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cd__panel.is-open {
max-height: 120px;
opacity: 1;
margin-top: 12px;
}
.cd__num {
margin: 0;
font-weight: 800;
font-size: clamp(15px, 4.4vw, 18px);
line-height: 1;
font-family: inherit;
color: #2f6df6;
font-variant-numeric: tabular-nums;
}
.cd__label {
margin: 4px 0 0;
font-weight: 600;
font-size: 9px;
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px), (max-height: 220px) {
.cd {
padding: 12px;
}
.cd__blurb {
display: none;
}
.cd__label {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd__accent {
animation: none !important;
}
.cd__panel {
transition: none;
}
}// 실제 동작: "結果を見る"를 누르면 패널이 실제로 펼쳐지고, 숫자 세 개가 0에서 목표값까지 올라간다.
var btn = document.querySelector('.cd__toggle');
var panel = document.querySelector('.cd__panel');
var nums = panel.querySelectorAll('.cd__num');
var hasCounted = false;
function countUp() {
if (hasCounted) return;
hasCounted = true;
var startTime = performance.now();
var duration = 900;
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
function tick(now) {
var t = Math.min(1, (now - startTime) / duration);
var e = easeOut(t);
nums.forEach(function (el) {
var target = Number(el.getAttribute('data-target'));
var suffix = el.getAttribute('data-suffix');
el.textContent = Math.round(target * e) + suffix;
});
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
btn.addEventListener('click', function () {
var open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
panel.classList.toggle('is-open', !open);
if (!open) countUp();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProjectCaseDetail.module.scss";
export type ProjectCaseDetailProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const STATS = [
{ target: 38, suffix: "%", label: "전환율 상승" },
{ target: 6, suffix: "주", label: "제작 기간" },
{ target: 210, suffix: "%", label: "트래픽 증가" },
];
export default function ProjectCaseDetail({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: ProjectCaseDetailProps) {
const [open, setOpen] = useState(false);
const [nums, setNums] = useState(STATS.map(() => 0));
const played = useRef(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 실제 동작: "결과 보기"를 누르면 패널이 실제로 펼쳐지고, 숫자 세 개가 0에서 목표값까지 올라간다.
function countUp() {
if (played.current) return;
played.current = true;
const start = performance.now();
const len = 900;
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
function tick(now: number) {
const t = Math.min(1, (now - start) / len);
const e = easeOut(t);
setNums(STATS.map((s) => Math.round(s.target * e)));
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
function toggle() {
const next = !open;
setOpen(next);
if (next) countUp();
}
return (
<section className={styles.cd} style={vars}>
<span className={styles.accent} aria-hidden="true" />
<p className={styles.eyebrow}>CASE STUDY</p>
<h2 className={styles.title}>결제 플로우 리디자인</h2>
<p className={styles.blurb}>결제 단계 5→3단계로 줄이고, 이탈 지점을 다시 그렸습니다.</p>
<button type="button" className={styles.toggle} aria-expanded={open} onClick={toggle}>
결과 보기
</button>
<div className={`${styles.panel} ${open ? styles.panelOpen : ""}`} role="status" aria-live="polite">
{STATS.map((s, i) => (
<div key={s.label} className={styles.stat}>
<p className={styles.num}>{nums[i]}{s.suffix}</p>
<p className={styles.label}>{s.label}</p>
</div>
))}
</div>
</section>
);
}03 ケース詳細 — 計測 累積面積 0.461% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · ポートフォリオサイト テンプレート9選 — プロジェクト紹介セクション
このデモが属する記事
ポートフォリオサイト テンプレート9選 — プロジェクト紹介セクションポートフォリオサイト テンプレートは、紹介ヒーロー・プロジェクトグリッド・ケース詳細・経歴タイムライン・連絡フォームなど、個人のポートフォリオページに使うセクションをまとめて配るコード集です。ランディングページ(LP)にそのまま貼れる紹介ヒ この記事のデモをすべて見る


