08履歴書CTA
'PDF 내려받기' 버튼을 누르면 진행바가 실제로 채워진 뒤 화살표 아이콘이 체크로 바뀌고, 잠시 뒤 원래 상태로 되돌아갑니다.
使いどころ
scaleXsetTimeoutstroke-dashoffset

<main class="stage">
<section class="rc">
<span class="rc__accent" aria-hidden="true"></span>
<p class="rc__eyebrow">RESUME</p>
<h2 class="rc__title">履歴書PDFを受け取る</h2>
<button class="rc__btn" type="button">
<span class="rc__fill" aria-hidden="true"></span>
<span class="rc__ic-wrap" aria-hidden="true">
<svg class="rc__ic-arrow" viewBox="0 0 20 20"><polyline points="10,3 10,13" /><polyline points="5,9 10,14 15,9" /></svg>
<svg class="rc__ic-check" viewBox="0 0 20 20"><polyline points="4,10 8,14 16,5" /></svg>
</span>
<span class="rc__label">PDFをダウンロード</span>
</button>
<p class="rc__status" role="status" aria-live="polite"></p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 900ms; // 진행바 채워지는 시간(rc__btn.is-loading)
$easing: $ease-out;
$color: $subject-blue; // 버튼·진행바 색
@include stage($stage-paper);
.rc { width: min(300px, 92vw); text-align: center; }
// ── 상단 악센트 바가 계속 스캔하는 장식(항상 재생) ──────────────
.rc__accent {
display: block;
width: $sp-8; height: 3px;
margin: 0 auto $sp-2;
border-radius: $r-xs;
background: $color;
transform-origin: center;
animation: rc-scan 2s $ease-spring infinite;
}
@keyframes rc-scan {
0%, 70%, 100% { transform: scaleX(.3); opacity: .5; }
30% { transform: scaleX(1); opacity: 1; }
}
.rc__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); } // 흰 면 위 7.26:1
.rc__title { margin: 0 0 $sp-3; font-weight: 800; font-size: clamp(14px, 4vw, 16px); line-height: 1.2; font-family: inherit; color: $subject-ink; }
.rc__btn {
position: relative;
overflow: hidden;
display: inline-flex; align-items: center; gap: $sp-1;
padding: $sp-2 $sp-5;
border: 0;
border-radius: $r-control;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700; font-size: 12px; line-height: 1.4; font-family: inherit;
cursor: pointer;
isolation: isolate;
}
.rc__fill {
position: absolute; inset: 0;
background: rgba(255, 255, 255, .22);
transform: scaleX(0);
transform-origin: left; // 규칙8: 버튼 왼쪽 채움 오버레이
z-index: -1;
}
.rc__btn.is-loading .rc__fill { transform: scaleX(1); transition: transform $duration $easing; }
.rc__ic-wrap { position: relative; width: 14px; height: 14px; }
.rc__ic-arrow, .rc__ic-check {
position: absolute; inset: 0;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
transition: opacity $dur-quick $ease-pop;
}
.rc__ic-arrow {
opacity: 1;
animation: rc-bob 2s ease-in-out infinite;
}
.rc__ic-check {
opacity: 0;
stroke-dasharray: 18;
stroke-dashoffset: 18;
}
.rc__btn.is-done .rc__ic-arrow { opacity: 0; animation: none; }
.rc__btn.is-done .rc__ic-check { opacity: 1; stroke-dashoffset: 0; transition: stroke-dashoffset $dur-base $ease-pop, opacity $dur-quick $ease-pop; }
// ── 다운로드 화살표가 계속 살짝 튀는 장식(항상 재생) ──────────────
@keyframes rc-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(3px); }
}
.rc__btn.is-loading .rc__ic-arrow { animation-play-state: paused; }
.rc__label { position: relative; }
.rc__btn.is-done .rc__label::after { content: " 완료"; }
.rc__status { min-height: 14px; margin: $sp-2 0 0; font-weight: 600; font-size: 9px; line-height: 1.4; font-family: inherit; color: rgba($subject-ink, .72); } // 클릭 뒤에만 드러나는 글자 — 3.97 이라 같이 올렸다
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.rc__eyebrow { display: none; }
.rc__title { margin-bottom: $sp-2; font-size: 13px; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.rc__accent { animation: none !important; }
.rc__ic-arrow { animation: none !important; }
.rc__fill, .rc__ic-check { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rc {
width: min(300px, 92vw);
text-align: center;
}
.rc__accent {
display: block;
width: 32px;
height: 3px;
margin: 0 auto 8px;
border-radius: 4px;
background: #2f6df6;
transform-origin: center;
animation: rc-scan 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes rc-scan {
0%, 70%, 100% {
transform: scaleX(0.3);
opacity: 0.5;
}
30% {
transform: scaleX(1);
opacity: 1;
}
}
.rc__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);
}
.rc__title {
margin: 0 0 12px;
font-weight: 800;
font-size: clamp(14px, 4vw, 16px);
line-height: 1.2;
font-family: inherit;
color: #17141a;
}
.rc__btn {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 20px;
border: 0;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
font-family: inherit;
cursor: pointer;
isolation: isolate;
}
.rc__fill {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.22);
transform: scaleX(0);
transform-origin: left;
z-index: -1;
}
.rc__btn.is-loading .rc__fill {
transform: scaleX(1);
transition: transform 900ms ease-out;
}
.rc__ic-wrap {
position: relative;
width: 14px;
height: 14px;
}
.rc__ic-arrow, .rc__ic-check {
position: absolute;
inset: 0;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rc__ic-arrow {
opacity: 1;
animation: rc-bob 2s ease-in-out infinite;
}
.rc__ic-check {
opacity: 0;
stroke-dasharray: 18;
stroke-dashoffset: 18;
}
.rc__btn.is-done .rc__ic-arrow {
opacity: 0;
animation: none;
}
.rc__btn.is-done .rc__ic-check {
opacity: 1;
stroke-dashoffset: 0;
transition: stroke-dashoffset 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes rc-bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(3px);
}
}
.rc__btn.is-loading .rc__ic-arrow {
animation-play-state: paused;
}
.rc__label {
position: relative;
}
.rc__btn.is-done .rc__label::after {
content: " 완료";
}
.rc__status {
min-height: 14px;
margin: 8px 0 0;
font-weight: 600;
font-size: 9px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px), (max-height: 220px) {
.rc__eyebrow {
display: none;
}
.rc__title {
margin-bottom: 8px;
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.rc__accent {
animation: none !important;
}
.rc__ic-arrow {
animation: none !important;
}
.rc__fill, .rc__ic-check {
transition: none;
}
}// 실제 동작: 클릭하면 진행바가 실제로 채워진 뒤 체크 아이콘으로 바뀌고, 잠시 뒤 원래 상태로 돌아간다.
var btn = document.querySelector('.rc__btn');
var status = document.querySelector('.rc__status');
var isBusy = false;
btn.addEventListener('click', function () {
if (isBusy) return;
isBusy = true;
btn.classList.add('is-loading');
status.textContent = '準備中';
setTimeout(function () {
btn.classList.remove('is-loading');
btn.classList.add('is-done');
status.textContent = 'ダウンロード完了';
setTimeout(function () {
btn.classList.remove('is-done');
status.textContent = '';
isBusy = false;
}, 1600);
}, 900);
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProjectResumeCta.module.scss";
export type ProjectResumeCtaProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Status = "idle" | "loading" | "done";
export default function ProjectResumeCta({ duration = 900, easing = "ease-out", color = "#2f6df6" }: ProjectResumeCtaProps) {
const [status, setStatus] = useState<Status>("idle");
const busy = useRef(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 실제 동작: 클릭하면 진행바가 실제로 채워진 뒤 체크 아이콘으로 바뀌고, 잠시 뒤 원래 상태로 되돌아간다.
function onClick() {
if (busy.current) return;
busy.current = true;
setStatus("loading");
window.setTimeout(() => {
setStatus("done");
window.setTimeout(() => {
setStatus("idle");
busy.current = false;
}, 1600);
}, duration);
}
return (
<section className={styles.rc} style={vars}>
<span className={styles.accent} aria-hidden="true" />
<p className={styles.eyebrow}>RESUME</p>
<h2 className={styles.title}>이력서 PDF 받기</h2>
<button type="button" className={`${styles.btn} ${status === "loading" ? styles.loading : ""} ${status === "done" ? styles.done : ""}`} onClick={onClick}>
<span className={styles.fill} aria-hidden="true" />
<span className={styles.icWrap} aria-hidden="true">
<svg className={styles.icArrow} viewBox="0 0 20 20"><polyline points="10,3 10,13" /><polyline points="5,9 10,14 15,9" /></svg>
<svg className={styles.icCheck} viewBox="0 0 20 20"><polyline points="4,10 8,14 16,5" /></svg>
</span>
<span className={styles.label}>PDF 내려받기{status === "done" ? " 완료" : ""}</span>
</button>
<p className={styles.status} role="status" aria-live="polite">
{status === "loading" ? "준비 중" : status === "done" ? "다운로드 완료" : ""}
</p>
</section>
);
}08 履歴書CTA — 計測 累積面積 0.111% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · ポートフォリオサイト テンプレート9選 — プロジェクト紹介セクション
このデモが属する記事
ポートフォリオサイト テンプレート9選 — プロジェクト紹介セクションポートフォリオサイト テンプレートは、紹介ヒーロー・プロジェクトグリッド・ケース詳細・経歴タイムライン・連絡フォームなど、個人のポートフォリオページに使うセクションをまとめて配るコード集です。ランディングページ(LP)にそのまま貼れる紹介ヒ この記事のデモをすべて見る


