06話し手が一拍遅れて現れる出典行
引用が落ち着いた後、イニシャルの丸が跳び出し、名前と肩書きが続いて浮かび、下線が左から右へ満ちます。
使いどころ編集インタビュー、顧客の言葉
translateYscaleXcolor

<main class="stage">
<figure class="qc is-demo" aria-label="出典が遅れて現れる引用">
<blockquote class="qc__body">理解は足し算ではなく引き算だ。</blockquote>
<figcaption class="qc__src">
<span class="qc__disc" aria-hidden="true">紙</span>
<span class="qc__who">
<span class="qc__name">紙面編集者</span>
<span class="qc__role">月刊誌インタビュー</span>
</span>
</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// 에디토리얼 인용구 — 본문이 먼저 자리 잡고, 출처가 한 박자 늦게 마저 드러난다:
// 이니셜 원이 튀어나오고(scale) 이름·소속이 따라 올라오며(translateY), 밑줄이 좌에서 우로 차오른다(scaleX).
// 실사용에서는 출처 줄에 손을 올리면 이름 색이 깊어진다(color transition — 화면에 있는 값만 바꾼다).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.qc {
box-sizing: border-box;
width: min(300px, 92vw);
margin: 0;
display: grid;
gap: $sp-4;
font-family: inherit;
}
.qc * { box-sizing: border-box; }
.qc__body {
margin: 0;
font-size: 16px;
font-weight: 700;
line-height: 1.6;
color: $color;
}
.qc__src {
display: flex;
align-items: center;
gap: $sp-3;
padding-bottom: $sp-2;
position: relative;
}
// 밑줄 — 출처 줄 아래 가장자리에서 좌에서 우로
.qc__src::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .8);
transform-origin: left center; // 규칙8: 채움 방향 — 밑줄은 왼쪽 변에서 scaleX 로 그어진다
}
.qc__disc {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: $subject-ink;
color: $subject-cream;
font-size: 13px;
font-weight: 700;
line-height: 1;
}
.qc__who {
display: grid;
gap: $sp-1;
}
.qc__name {
font-size: 12px;
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
transition-property: color;
transition-duration: 160ms;
}
.qc__role {
font-size: 10px;
font-weight: 500;
line-height: 1.3;
color: rgba($subject-ink, .72);
}
// 실사용 호버 — 이름 색이 한 단계 깊어진다
.qc__src:hover .qc__name { color: rgba(60, 40, 10, 1); }
// ── 미리보기 루프 ─────────────────
.qc.is-demo .qc__body {
animation-name: qcBody;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.qc.is-demo .qc__disc {
animation-name: qcDisc;
animation-duration: $duration;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
.qc.is-demo .qc__name,
.qc.is-demo .qc__role {
animation-name: qcWho;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.qc.is-demo .qc__role { animation-delay: 60ms; animation-fill-mode: backwards; }
.qc.is-demo .qc__src::after {
transform-origin: left center; // 규칙8: 채움 방향 — 밑줄은 왼쪽 변에서 scaleX 로 그어진다
animation-name: qcRule;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 입장: 본문·이니셜 원·이름이 6~24% 구간에 겹쳐 움직인다(273 선례 — 여럿이 동시에 움직이는
// 입장 프레임이 개별 요소의 퇴장 프레임보다 커야 poster 가 산다). 밑줄은 마지막에 좌→우.
// 퇴장: 넷을 서로 다른 구간에 12%+ 걸쳐 느긋한 페이드로 흘린다(한 프레임 컷 금지 —
// 본문 통째로 사라지는 프레임의 면적이 입장을 이기고 빈 화면이 poster 가 된다, 248-09 함정).
@keyframes qcBody {
0%, 4% { opacity: 0; transform: translateY(8px); }
16%, 58% { opacity: 1; transform: translateY(0); }
70%, 100% { opacity: 0; }
}
// 이니셜 원: 본문과 겹쳐 튀어나옴(overshoot)
@keyframes qcDisc {
0%, 6% { opacity: 0; transform: scale(.3); }
18% { opacity: 1; transform: scale(1.12); }
24%, 62% { opacity: 1; transform: scale(1); }
76%, 100% { opacity: 0; }
}
// 이름·소속: 원 뒤로 따라 올라옴(같은 구간에 겹침)
@keyframes qcWho {
0%, 10% { opacity: 0; transform: translateY(6px); }
24%, 60% { opacity: 1; transform: translateY(0); }
74%, 100% { opacity: 0; }
}
// 밑줄: 마지막에 좌→우, 퇴장은 우에서 좌로 scaleX 로 흘러 사라짐(면적이 점진적으로 줄어든다)
@keyframes qcRule {
0%, 28% { transform: scaleX(0); }
46%, 66% { transform: scaleX(1); }
82%, 100% { transform: scaleX(0); }
}
@media (prefers-reduced-motion: reduce) {
.qc.is-demo .qc__body,
.qc.is-demo .qc__disc,
.qc.is-demo .qc__name,
.qc.is-demo .qc__role,
.qc.is-demo .qc__src::after {
animation: none !important;
}
}
@media (max-width: 340px) {
.qc { gap: $sp-3; }
.qc__body { font-size: 15px; }
.qc__disc { width: 28px; height: 28px; font-size: 12px; }
}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);
}
.qc {
box-sizing: border-box;
width: min(300px, 92vw);
margin: 0;
display: grid;
gap: 16px;
font-family: inherit;
}
.qc * {
box-sizing: border-box;
}
.qc__body {
margin: 0;
font-size: 16px;
font-weight: 700;
line-height: 1.6;
color: #17141a;
}
.qc__src {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 8px;
position: relative;
}
.qc__src::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.8);
transform-origin: left center;
}
.qc__disc {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: #17141a;
color: #fff7e6;
font-size: 13px;
font-weight: 700;
line-height: 1;
}
.qc__who {
display: grid;
gap: 4px;
}
.qc__name {
font-size: 12px;
font-weight: 700;
line-height: 1.3;
color: #17141a;
transition-property: color;
transition-duration: 160ms;
}
.qc__role {
font-size: 10px;
font-weight: 500;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.qc__src:hover .qc__name {
color: rgb(60, 40, 10);
}
.qc.is-demo .qc__body {
animation-name: qcBody;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.qc.is-demo .qc__disc {
animation-name: qcDisc;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.qc.is-demo .qc__name,
.qc.is-demo .qc__role {
animation-name: qcWho;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.qc.is-demo .qc__role {
animation-delay: 60ms;
animation-fill-mode: backwards;
}
.qc.is-demo .qc__src::after {
transform-origin: left center;
animation-name: qcRule;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes qcBody {
0%, 4% {
opacity: 0;
transform: translateY(8px);
}
16%, 58% {
opacity: 1;
transform: translateY(0);
}
70%, 100% {
opacity: 0;
}
}
@keyframes qcDisc {
0%, 6% {
opacity: 0;
transform: scale(0.3);
}
18% {
opacity: 1;
transform: scale(1.12);
}
24%, 62% {
opacity: 1;
transform: scale(1);
}
76%, 100% {
opacity: 0;
}
}
@keyframes qcWho {
0%, 10% {
opacity: 0;
transform: translateY(6px);
}
24%, 60% {
opacity: 1;
transform: translateY(0);
}
74%, 100% {
opacity: 0;
}
}
@keyframes qcRule {
0%, 28% {
transform: scaleX(0);
}
46%, 66% {
transform: scaleX(1);
}
82%, 100% {
transform: scaleX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.qc.is-demo .qc__body,
.qc.is-demo .qc__disc,
.qc.is-demo .qc__name,
.qc.is-demo .qc__role,
.qc.is-demo .qc__src::after {
animation: none !important;
}
}
@media (max-width: 340px) {
.qc {
gap: 12px;
}
.qc__body {
font-size: 15px;
}
.qc__disc {
width: 28px;
height: 28px;
font-size: 12px;
}
}// QuoteCiteReveal — vanilla 판(run/282/demo/06-quote-cite-reveal) 의 React 포트.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./QuoteCiteReveal.module.scss";
export type QuoteCiteRevealProps = {
quote?: string;
initial?: string;
name?: string;
role?: string;
groupLabel?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function QuoteCiteReveal({
quote = "이해는 더하기가 아니라 빼기다.",
initial = "지",
name = "지면 편집자",
role = "월간 잡지 인터뷰",
groupLabel = "출처가 늦게 드러나는 인용구",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: QuoteCiteRevealProps) {
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onEnter() {
setLive(false);
}
const cls = [styles.qc, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<figure className={cls} style={vars} aria-label={groupLabel} onMouseEnter={onEnter}>
<blockquote className={styles.body}>{quote}</blockquote>
<figcaption className={styles.src}>
<span className={styles.disc} aria-hidden="true">{initial}</span>
<span className={styles.who}>
<span className={styles.name}>{name}</span>
<span className={styles.role}>{role}</span>
</span>
</figcaption>
</figure>
);
}06 話し手が一拍遅れて現れる出典行 — 計測 累積面積 3.441% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · 引用 デザイン CSS 9選 — コピペで決まる引用ブロック
このデモが属する記事
引用 デザイン CSS 9選 — コピペで決まる引用ブロック引用 デザインとは、他人の一文を自分の文章の中にきれいに座らせるための引用ブロックのことです。このページにはそのブロックを9種、実際に動いた状態で並べてあり、グリッドの下のコードはそっくりコピーしてブログにコピペで決まる形にしてあります。左 この記事のデモをすべて見る


