Blockquote CSS: 9 Quote Box Designs (Copy-Paste)
Blockquote CSS places another writer's sentence inside your own text. Nine boxes run live below, every snippet Copy-Paste ready — from a bar-grow quote to
01 quote-bar-rise
02 quote-mark-pop
03 quote-pull-center
04 quote-tint-fill
05 quote-bracket-slide
06 quote-cite-reveal
07 quote-ink-invert
08 quote-watermark-fade
09 quote-hover-swap
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Quote that opens as the side bar grows
- 02 Quote with a bouncing quotation mark
- 03 Pull quote settling into the middle of the text
- 04 Quote box filling left to right
- 05 Quote framed by marks sliding inward
- 06 Source line revealing the speaker a beat late
- 07 Quote as an ink block flipping in
- 08 Quote with a giant mark ghosting behind
- 09 Quote that swaps lines on hover
The nine blockquote css examples are ordered by the job a quote does in a piece of writing, not by difficulty. You meet the default bar-grow box first (01), then the mark arrives before the sentence (02), the sentence takes over the middle of the page (03), a box fills with color (04), the frame tightens from both ends (05), the speaker shows up a beat late (06), the whole thing flips to ink (07), the background deepens (08), and only at the end does the quote respond to your hand (09). All nine share the same color, spacing, and easing tokens, so you can mix several in one article without the tone drifting. Grow a quote from one sentence into a person's word and it becomes testimonial section design; cut the same paper box to hold an offer and you get coupon design.
01Quote that opens as the side bar grows
What this blockquote design css shows
The default quote for a blog post body. The thick bar on the left fills top to bottom with scaleY to claim its place, then two lines and the citation settle in a beat apart.

<main class="stage">
<figure class="qb is-demo" aria-label="Quote that opens as the side bar grows">
<span class="qb__bar" aria-hidden="true"></span>
<div class="qb__content">
<blockquote class="qb__body">
<p class="qb__l1">Good writing is written twice.</p>
<p class="qb__l2">First for the thinking, then for the reader.</p>
</blockquote>
<figcaption class="qb__cite">— Editors' rule of thumb</figcaption>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// 블로그 본문 기본 인용구 — 왼쪽 굵은 막대가 scaleY 로 위에서 아래로 자라며 자리를 만들고,
// 문장 둘과 출처가 한 박자씩 차이 나게 내려앉는다. 실제 글에서는 화면에 들어올 때 한 번 재생.
// 격자 미리보기는 .is-demo 가 2초 루프로 돌리고, 복귀는 요소별로 잘게 나눠 흘린다(273-01 선례) —
// 막대+문장이 동시에 움직이는 입장 순간이 최대 변화 프레임으로 남아야 대표 이미지가 산다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.qb {
box-sizing: border-box;
width: min(320px, 92vw);
margin: 0;
padding: $sp-4 $sp-5;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
display: grid;
grid-template-columns: 6px 1fr;
gap: $sp-4;
font-family: inherit;
}
.qb * { box-sizing: border-box; }
.qb__bar {
border-radius: $r-pill;
background: $color;
}
.qb__content { display: grid; }
.qb__body {
margin: 0;
display: grid;
gap: $sp-2;
}
.qb__l1,
.qb__l2 {
margin: 0;
font-size: 14px;
font-weight: 600;
line-height: 1.5;
}
.qb__cite {
margin: $sp-3 0 0;
font-size: 11px;
font-weight: 500;
line-height: 1.4;
color: rgba(23, 20, 26, .62);
}
// ── 미리보기 루프(정적 규칙 먼저, 그 위에 덧씌움) ─────────────────
.qb.is-demo .qb__bar {
transform-origin: top center; // 규칙8: 채움 방향 — 인용 막대는 위에서 아래로 scaleY 로 차오른다(중심이면 가운데서 벌어진다)
animation-name: qbBar;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.qb.is-demo .qb__l1 {
animation-name: qbLine1;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.qb.is-demo .qb__l2 {
animation-name: qbLine2;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.qb.is-demo .qb__cite {
animation-name: qbCite;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 막대: 0~26% 자람 → 80~90% 걷어냄(한 프레임 컷이 아니라 scaleY 로 흘려 면적 분할)
@keyframes qbBar {
0% { transform: scaleY(0); }
26%, 80% { transform: scaleY(1); }
90%, 100% { transform: scaleY(0); }
}
// 문장 1: 막대가 반쯤 찬 10%부터 출발
@keyframes qbLine1 {
0%, 10% { opacity: 0; transform: translateY(8px); }
34%, 76% { opacity: 1; transform: translateY(0); }
80%, 100% { opacity: 0; }
}
// 문장 2: 한 박자 뒤(20%)
@keyframes qbLine2 {
0%, 20% { opacity: 0; transform: translateY(8px); }
44%, 72% { opacity: 1; transform: translateY(0); }
76%, 100% { opacity: 0; }
}
// 출처: 마지막에 짧게
@keyframes qbCite {
0%, 30% { opacity: 0; transform: translateY(6px); }
52%, 68% { opacity: 1; transform: translateY(0); }
72%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.qb.is-demo .qb__bar,
.qb.is-demo .qb__l1,
.qb.is-demo .qb__l2,
.qb.is-demo .qb__cite {
animation: none !important;
}
}
@media (max-width: 340px) {
.qb { padding: $sp-3 $sp-4; }
.qb__l1, .qb__l2 { font-size: 13px; }
.qb__cite { margin-top: $sp-2; }
}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);
}
.qb {
box-sizing: border-box;
width: min(320px, 92vw);
margin: 0;
padding: 16px 20px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
display: grid;
grid-template-columns: 6px 1fr;
gap: 16px;
font-family: inherit;
}
.qb * {
box-sizing: border-box;
}
.qb__bar {
border-radius: 999px;
background: #2f6df6;
}
.qb__content {
display: grid;
}
.qb__body {
margin: 0;
display: grid;
gap: 8px;
}
.qb__l1,
.qb__l2 {
margin: 0;
font-size: 14px;
font-weight: 600;
line-height: 1.5;
}
.qb__cite {
margin: 12px 0 0;
font-size: 11px;
font-weight: 500;
line-height: 1.4;
color: rgba(23, 20, 26, 0.62);
}
.qb.is-demo .qb__bar {
transform-origin: top center;
animation-name: qbBar;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.qb.is-demo .qb__l1 {
animation-name: qbLine1;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.qb.is-demo .qb__l2 {
animation-name: qbLine2;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.qb.is-demo .qb__cite {
animation-name: qbCite;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes qbBar {
0% {
transform: scaleY(0);
}
26%, 80% {
transform: scaleY(1);
}
90%, 100% {
transform: scaleY(0);
}
}
@keyframes qbLine1 {
0%, 10% {
opacity: 0;
transform: translateY(8px);
}
34%, 76% {
opacity: 1;
transform: translateY(0);
}
80%, 100% {
opacity: 0;
}
}
@keyframes qbLine2 {
0%, 20% {
opacity: 0;
transform: translateY(8px);
}
44%, 72% {
opacity: 1;
transform: translateY(0);
}
76%, 100% {
opacity: 0;
}
}
@keyframes qbCite {
0%, 30% {
opacity: 0;
transform: translateY(6px);
}
52%, 68% {
opacity: 1;
transform: translateY(0);
}
72%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.qb.is-demo .qb__bar,
.qb.is-demo .qb__l1,
.qb.is-demo .qb__l2,
.qb.is-demo .qb__cite {
animation: none !important;
}
}
@media (max-width: 340px) {
.qb {
padding: 12px 16px;
}
.qb__l1, .qb__l2 {
font-size: 13px;
}
.qb__cite {
margin-top: 8px;
}
}// QuoteBarRise — vanilla 판(run/282/demo/01-quote-bar-rise) 의 React 포트.
// 변수 3개(duration·easing·color)는 props 로 받아 CSS 변수(var(--duration) 등)로 들어온다.
import { useState } from "react";
import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react";
import styles from "./QuoteBarRise.module.scss";
export type QuoteBarRiseProps = {
line1?: string;
line2?: string;
cite?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function QuoteBarRise({
line1 = "좋은 글은 두 번 쓴다.",
line2 = "먼저 생각을 위해, 다음엔 독자를 위해.",
cite = "— 편집실 수칙",
groupLabel = "막대가 자라며 열리는 인용구",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: QuoteBarRiseProps) {
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 실사용에서는 카드에 손을 얹는 순간 루프를 뗀다(정적 상태 = 완성된 인용구)
function onEnter() {
setLive(false);
}
const cls = [styles.qb, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<figure className={cls} style={vars} aria-label={groupLabel} onMouseEnter={onEnter}>
<span className={styles.bar} aria-hidden="true"></span>
<div className={styles.content}>
<blockquote className={styles.body}>
<p className={styles.l1}>{line1}</p>
<p className={styles.l2}>{line2}</p>
</blockquote>
<figcaption className={styles.cite}>{cite}</figcaption>
</div>
</figure>
);
}01 · Quote that opens as the side bar grows — Measured area 3.683% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The changed area accumulates to 3.683% of the canvas across the loop, and the box is plain enough for any magazine article.
// The bar — grows downward (scaleY), so the area arrives split across frames instead of one cut
.qb__bar {
border-radius: $r-pill;
background: $color;
}
.qb.is-demo .qb__bar {
transform-origin: top center;
animation-name: qbBar;
animation-duration: $duration; // 2s loop
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes qbBar {
0% { transform: scaleY(0); }
26%, 80% { transform: scaleY(1); }
90%, 100% { transform: scaleY(0); }
}
// The two lines share keyframes with shifted percentages — a beat apart without animation-delay
@keyframes qbLine1 {
0%, 10% { opacity: 0; transform: translateY(8px); }
34%, 76% { opacity: 1; transform: translateY(0); }
80%, 100% { opacity: 0; }
}
02Quote with a bouncing quotation mark
What happens on screen
This one belongs at the top of a help or getting-started page. The big quotation mark above the text pops once on a cubic-bezier(.2, 1.4, .4, 1) curve, overshooting to 1.14 before landing, while the two body lines slide in from the left (accumulated area 3.828%, intensity 71.7).

<main class="stage">
<figure class="qm is-demo" aria-label="Quote with a bouncing quotation mark">
<svg class="qm__mark" viewBox="0 0 256 256" width="44" height="44" fill="currentColor" aria-hidden="true"><g><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-8-16m0 80h-60V72h60Z"/></g></svg>
<blockquote class="qm__body">
<p class="qm__l1">Simplify the screen,</p>
<p class="qm__l2">and its purpose becomes clear.</p>
</blockquote>
<figcaption class="qm__cite">— First line of the getting-started guide</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// 큰따옴표 아이콘(Phosphor Duotone quotes)이 scale 로 한 번 튀어 올라 자리를 잡고,
// 본문 두 줄이 오른쪽에서 밀려 들어오는 도움말 첫머리용 인용구.
// 아이콘의 overshoot 는 $ease-pop(cubic-bezier(.2,1.4,.4,1)) — y 가 1을 넘는 커브로 튕김을 만든다.
// 복귀는 아이콘 걷어냄+문장 컷을 서로 다른 지점에 두고, 입장(튐+슬라이드 동시)이 최대 변화로 남게.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.qm {
box-sizing: border-box;
width: min(300px, 92vw);
margin: 0;
display: grid;
justify-items: start;
gap: $sp-3;
color: $color;
font-family: inherit;
}
.qm * { box-sizing: border-box; }
.qm__mark {
display: block;
color: $subject-ink;
}
.qm__body {
margin: 0;
display: grid;
gap: $sp-1;
}
.qm__l1,
.qm__l2 {
margin: 0;
font-size: 15px;
font-weight: 700;
line-height: 1.5;
}
.qm__cite {
margin: 0;
font-size: 11px;
font-weight: 500;
line-height: 1.4;
color: rgba($subject-ink, .72);
}
// ── 미리보기 루프 ─────────────────
.qm.is-demo .qm__mark {
transform-origin: left bottom; // 규칙8: 붙는 지점 — 본문과 공유하는 왼쪽 정렬선·본문 위 모서리에 뿌리박은 채 1.14 로 튄다(중심이면 작을 때 정렬선에서 떠오르고 overshoot 에서 정렬선을 왼쪽으로 넘는다)
animation-name: qmMark;
animation-duration: $duration;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
.qm.is-demo .qm__l1,
.qm.is-demo .qm__l2 {
animation-name: qmLine;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.qm.is-demo .qm__l1 { animation-delay: 80ms; animation-fill-mode: backwards; }
.qm.is-demo .qm__l2 { animation-delay: 180ms; animation-fill-mode: backwards; }
.qm.is-demo .qm__cite {
animation-name: qmCite;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 아이콘: 0~6% 나타나며 22%까지 1.14 배로 튀었다가 30%에 1 로 착지 → 78~90% 걷어냄
@keyframes qmMark {
0% { opacity: 0; transform: scale(.2); }
6% { opacity: 1; transform: scale(.92); }
22% { transform: scale(1.14); }
30%, 78% { opacity: 1; transform: scale(1); }
90%, 100% { opacity: 0; transform: scale(.9); }
}
// 본문: 왼쪽에서 밀려 들어옴(delay 로 한 박자 차이 — 무한 루프에서도 위상이 그대로 유지된다)
@keyframes qmLine {
0%, 4% { opacity: 0; transform: translateX(-14px); }
30%, 74% { opacity: 1; transform: translateX(0); }
80%, 100% { opacity: 0; }
}
@keyframes qmCite {
0%, 18% { opacity: 0; transform: translateX(-10px); }
42%, 70% { opacity: 1; transform: translateX(0); }
76%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.qm.is-demo .qm__mark,
.qm.is-demo .qm__l1,
.qm.is-demo .qm__l2,
.qm.is-demo .qm__cite {
animation: none !important;
}
}
@media (max-width: 340px) {
.qm { gap: $sp-2; }
.qm__l1, .qm__l2 { font-size: 14px; }
.qm__mark { width: 36px; height: 36px; }
}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);
}
.qm {
box-sizing: border-box;
width: min(300px, 92vw);
margin: 0;
display: grid;
justify-items: start;
gap: 12px;
color: #17141a;
font-family: inherit;
}
.qm * {
box-sizing: border-box;
}
.qm__mark {
display: block;
color: #17141a;
}
.qm__body {
margin: 0;
display: grid;
gap: 4px;
}
.qm__l1,
.qm__l2 {
margin: 0;
font-size: 15px;
font-weight: 700;
line-height: 1.5;
}
.qm__cite {
margin: 0;
font-size: 11px;
font-weight: 500;
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
.qm.is-demo .qm__mark {
transform-origin: left bottom;
animation-name: qmMark;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.qm.is-demo .qm__l1,
.qm.is-demo .qm__l2 {
animation-name: qmLine;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.qm.is-demo .qm__l1 {
animation-delay: 80ms;
animation-fill-mode: backwards;
}
.qm.is-demo .qm__l2 {
animation-delay: 180ms;
animation-fill-mode: backwards;
}
.qm.is-demo .qm__cite {
animation-name: qmCite;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes qmMark {
0% {
opacity: 0;
transform: scale(0.2);
}
6% {
opacity: 1;
transform: scale(0.92);
}
22% {
transform: scale(1.14);
}
30%, 78% {
opacity: 1;
transform: scale(1);
}
90%, 100% {
opacity: 0;
transform: scale(0.9);
}
}
@keyframes qmLine {
0%, 4% {
opacity: 0;
transform: translateX(-14px);
}
30%, 74% {
opacity: 1;
transform: translateX(0);
}
80%, 100% {
opacity: 0;
}
}
@keyframes qmCite {
0%, 18% {
opacity: 0;
transform: translateX(-10px);
}
42%, 70% {
opacity: 1;
transform: translateX(0);
}
76%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.qm.is-demo .qm__mark,
.qm.is-demo .qm__l1,
.qm.is-demo .qm__l2,
.qm.is-demo .qm__cite {
animation: none !important;
}
}
@media (max-width: 340px) {
.qm {
gap: 8px;
}
.qm__l1, .qm__l2 {
font-size: 14px;
}
.qm__mark {
width: 36px;
height: 36px;
}
}// QuoteMarkPop — vanilla 판(run/282/demo/02-quote-mark-pop) 의 React 포트.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./QuoteMarkPop.module.scss";
export type QuoteMarkPopProps = {
line1?: string;
line2?: string;
cite?: string;
groupLabel?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function QuotesMark() {
return (
<svg className={styles.mark} viewBox="0 0 256 256" width="44" height="44" fill="currentColor" aria-hidden="true"><g><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-8-16m0 80h-60V72h60Z"/></g></svg>
);
}
export default function QuoteMarkPop({
line1 = "화면을 단순하게 만들면",
line2 = "쓰임이 분명해진다.",
cite = "— 시작 가이드 첫 문장",
groupLabel = "따옴표가 튀어나오는 인용구",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: QuoteMarkPopProps) {
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onEnter() {
setLive(false);
}
const cls = [styles.qm, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<figure className={cls} style={vars} aria-label={groupLabel} onMouseEnter={onEnter}>
<QuotesMark />
<blockquote className={styles.body}>
<p className={styles.l1}>{line1}</p>
<p className={styles.l2}>{line2}</p>
</blockquote>
<figcaption className={styles.cite}>{cite}</figcaption>
</figure>
);
}02 · Quote with a bouncing quotation mark — Measured area 3.84% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The overshoot curve is the kind whose y value passes 1, so it needs transform-origin: left bottom to bounce from a rooted corner.
// The mark pop — a curve that passes 1 and comes back (overshoot)
.qm.is-demo .qm__mark {
transform-origin: left bottom;
animation-name: qmMark;
animation-duration: $duration;
animation-timing-function: $ease-pop; // cubic-bezier(.2, 1.4, .4, 1)
animation-iteration-count: infinite;
}
@keyframes qmMark {
0% { opacity: 0; transform: scale(.2); }
6% { opacity: 1; transform: scale(.92); }
22% { transform: scale(1.14); } // the overshoot before landing
30%, 78% { opacity: 1; transform: scale(1); }
90%, 100% { opacity: 0; transform: scale(.9); }
}
// Body slide gets its stagger from animation-delay — the phase holds even in an infinite loop
.qm.is-demo .qm__l2 { animation-delay: 180ms; animation-fill-mode: backwards; }
03Pull quote settling into the middle of the text
What moves
The magazine move: one big sentence caught between paragraphs. The small context lines cut in with steps(1, end) before the big lines settle, and the two big lines drop with animation-delay of 60ms and 160ms (accumulated area 4.747%, intensity 119.7).

<main class="stage">
<section class="qp is-demo" aria-label="Pull quote settling mid-article">
<p class="qp__ctx qp__ctx--pre">Readers come down the page one line at a time.</p>
<blockquote class="qp__pull" >
<span class="qp__a">Speed</span>
<span class="qp__b">is a feature.</span>
</blockquote>
<p class="qp__ctx qp__ctx--post">One big line in that flow lets them breathe.</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 매거진 pull quote — 본문 사이에 낀 큰 문장 두 줄이 animation-delay 로 한 박자씩 차이 나게 내려앉는다.
// 지연은 무한 루프에서도 위상차로 유지되고, animation-fill-mode: backwards 로 지연 구간에 첫 프레임(숨김)을 채운다
// (252-08 함정: fill 이 없으면 완성 상태가 0프레임에 번쩍한다).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-paper;
@include stage($stage-orange);
.qp {
box-sizing: border-box;
width: min(320px, 92vw);
margin: 0;
display: grid;
gap: $sp-4;
text-align: center;
font-family: inherit;
}
.qp * { box-sizing: border-box; }
.qp__ctx {
margin: 0;
font-size: 11px;
font-weight: 400;
line-height: 1.6;
color: $subject-ink;
}
.qp__pull {
margin: 0;
display: grid;
gap: $sp-1;
}
.qp__a,
.qp__b {
display: block;
font-size: 22px;
font-weight: 800;
line-height: 1.35;
color: $color;
letter-spacing: .04em;
}
// ── 미리보기 루프 ─────────────────
.qp.is-demo .qp__a,
.qp.is-demo .qp__b {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.qp.is-demo .qp__a { animation-name: qpDropA; animation-delay: 60ms; }
.qp.is-demo .qp__b { animation-name: qpDropB; animation-delay: 160ms; }
// 맥락 줄(앞·뒤 지문)은 steps 컷 — 페이드 중간값이 poster 에 반투명 유령으로 남는다(237 함정)
.qp.is-demo .qp__ctx {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.qp.is-demo .qp__ctx--pre { animation-name: qpCtxPre; }
.qp.is-demo .qp__ctx--post { animation-name: qpCtxPost; }
// 큰 문장 두 줄은 opacity 램프 없이 translateY 로만 내려앉는다(처음부터 불투명 — 텍스트 페이드의
// 중간값이 poster 에 유령으로 남는다). 두 줄이 겹쳐 내려오는 입장(60~400ms)이 최대 변화 프레임.
// 복귀는 다시 위로 올라갔다가 줄마다 다른 지점(86%·90%)에 멈춘다 — 마지막 샘플(95.8%) 전에 끝난다.
@keyframes qpDropA {
0%, 2% { opacity: 1; transform: translateY(18px); }
16%, 80% { opacity: 1; transform: translateY(0); }
86%, 100% { opacity: 1; transform: translateY(18px); }
}
@keyframes qpDropB {
0%, 6% { opacity: 1; transform: translateY(18px); }
22%, 76% { opacity: 1; transform: translateY(0); }
90%, 100% { opacity: 1; transform: translateY(18px); }
}
// 앞 지문: 큰 문장이 자리 잡기 전 컷, 큰 문장보다 먼저 물러난다
@keyframes qpCtxPre {
0%, 8% { opacity: 0; }
9%, 56% { opacity: 1; }
57%, 100% { opacity: 0; }
}
// 뒷 지문: 큰 문장이 다 내려앉은 뒤 컷, 앞 지문 다음에 물러난다
@keyframes qpCtxPost {
0%, 26% { opacity: 0; }
27%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.qp.is-demo .qp__a,
.qp.is-demo .qp__b,
.qp.is-demo .qp__ctx {
animation: none !important;
}
}
@media (max-width: 340px) {
.qp { gap: $sp-3; }
.qp__a, .qp__b { font-size: 20px; }
.qp__ctx { font-size: 10px; }
}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);
}
.qp {
box-sizing: border-box;
width: min(320px, 92vw);
margin: 0;
display: grid;
gap: 16px;
text-align: center;
font-family: inherit;
}
.qp * {
box-sizing: border-box;
}
.qp__ctx {
margin: 0;
font-size: 11px;
font-weight: 400;
line-height: 1.6;
color: #17141a;
}
.qp__pull {
margin: 0;
display: grid;
gap: 4px;
}
.qp__a,
.qp__b {
display: block;
font-size: 22px;
font-weight: 800;
line-height: 1.35;
color: #ffffff;
letter-spacing: 0.04em;
}
.qp.is-demo .qp__a,
.qp.is-demo .qp__b {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.qp.is-demo .qp__a {
animation-name: qpDropA;
animation-delay: 60ms;
}
.qp.is-demo .qp__b {
animation-name: qpDropB;
animation-delay: 160ms;
}
.qp.is-demo .qp__ctx {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.qp.is-demo .qp__ctx--pre {
animation-name: qpCtxPre;
}
.qp.is-demo .qp__ctx--post {
animation-name: qpCtxPost;
}
@keyframes qpDropA {
0%, 2% {
opacity: 1;
transform: translateY(18px);
}
16%, 80% {
opacity: 1;
transform: translateY(0);
}
86%, 100% {
opacity: 1;
transform: translateY(18px);
}
}
@keyframes qpDropB {
0%, 6% {
opacity: 1;
transform: translateY(18px);
}
22%, 76% {
opacity: 1;
transform: translateY(0);
}
90%, 100% {
opacity: 1;
transform: translateY(18px);
}
}
@keyframes qpCtxPre {
0%, 8% {
opacity: 0;
}
9%, 56% {
opacity: 1;
}
57%, 100% {
opacity: 0;
}
}
@keyframes qpCtxPost {
0%, 26% {
opacity: 0;
}
27%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.qp.is-demo .qp__a,
.qp.is-demo .qp__b,
.qp.is-demo .qp__ctx {
animation: none !important;
}
}
@media (max-width: 340px) {
.qp {
gap: 12px;
}
.qp__a, .qp__b {
font-size: 20px;
}
.qp__ctx {
font-size: 10px;
}
}// QuotePullCenter — vanilla 판(run/282/demo/03-quote-pull-center) 의 React 포트.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./QuotePullCenter.module.scss";
export type QuotePullCenterProps = {
preContext?: string;
pullA?: string;
pullB?: string;
postContext?: string;
groupLabel?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function QuotePullCenter({
preContext = "문서를 읽는 사람은 위에서 아래로 한 줄씩 내려온다.",
pullA = "속도는",
pullB = "기능이다.",
postContext = "그 흐름 속에서 큰 문장 하나가 숨을 고르게 한다.",
groupLabel = "본문 한가운데 내려앉는 강조 문장",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffffff",
}: QuotePullCenterProps) {
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onEnter() {
setLive(false);
}
const cls = [styles.qp, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel} onMouseEnter={onEnter}>
<p className={[styles.ctx, styles.ctxPre].join(" ")}>{preContext}</p>
<blockquote className={styles.pull}>
<span className={styles.a}>{pullA}</span>
<span className={styles.b}>{pullB}</span>
</blockquote>
<p className={[styles.ctx, styles.ctxPost].join(" ")}>{postContext}</p>
</section>
);
}03 · Pull quote settling into the middle of the text — Measured area 4.517% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Neither big line uses an opacity ramp — they move by translateY only, fully opaque from the start; the reason is measured in the trap section below.
// The big lines — settle down 18px from above, fully opaque the whole way (no fade mid-values)
.qp.is-demo .qp__a { animation-name: qpDropA; animation-delay: 60ms; }
.qp.is-demo .qp__b { animation-name: qpDropB; animation-delay: 160ms; }
.qp.is-demo .qp__a,
.qp.is-demo .qp__b {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards; // fill the delay with the first frame
}
@keyframes qpDropA {
0%, 2% { opacity: 1; transform: translateY(18px); }
16%, 80% { opacity: 1; transform: translateY(0); }
86%, 100% { opacity: 1; transform: translateY(18px); }
}
04Quote box filling left to right
What you see
Close to a tip box. A pale background laid under the content with ::before sweeps in from the left edge using scaleX with transform-origin: left; the label cuts in when the fill is halfway and the sentence slides in right behind the fill (the fill covers so much ground that the accumulated area lands at 21.95%, the largest of the nine).

<main class="stage">
<figure class="qt is-demo" aria-label="Quote box filling left to right">
<figcaption class="qt__label">Key point</figcaption>
<blockquote class="qt__body">Before changing a value,<br>read what is already on screen.</blockquote>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// 요점 박스형 인용구 — ::before 로 깔린 옅은 배경이 scaleX(transform-origin: left) 로 좌에서 우로 차오르고,
// 채워진 만큼 문장이 뒤따라 드러난다. 실사용에서는 상자에 손을 올리면 라벨 색이 한 박자 늦게 깊어진다
// (transition-delay — 채워지는 리듬을 호버에서도 유지하는 소질).
// 복귀: 배경은 우에서 좌로 scaleX 로 흘러 나가고(컷 아님 — 한 프레임 면적이 입장보다 작게),
// 라벨·문장은 서로 다른 지점에 컷. 입장(배경 채움+문장 등장이 겹치는 구간)이 최대 변화 프레임.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-lilac;
@include stage($stage-paper);
.qt {
box-sizing: border-box;
position: relative;
width: min(320px, 92vw);
margin: 0;
padding: $sp-4 $sp-5;
border: 1px solid rgba(185, 160, 255, .55);
border-radius: $r-card;
overflow: hidden;
font-family: inherit;
isolation: isolate;
}
.qt * { box-sizing: border-box; }
// 차오르는 배경 — 내용보다 아래 층에 깔고 z-index 로 순서를 못박는다
.qt::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
background: $color;
opacity: .3;
}
.qt__label,
.qt__body {
position: relative;
z-index: 1;
margin: 0;
display: block;
}
.qt__label {
font-size: 11px;
font-weight: 700;
line-height: 1.4;
letter-spacing: .08em;
color: rgba(85, 55, 180, .9);
transition-property: color;
transition-duration: 140ms;
transition-delay: 80ms;
}
.qt__body {
margin-top: $sp-2;
font-size: 14px;
font-weight: 600;
line-height: 1.6;
color: $subject-ink;
}
// 실사용 호버 — 라벨이 채워지는 리듬처럼 한 박자 늦게 깊어진다
.qt:hover .qt__label { color: rgba(60, 30, 150, 1); }
// ── 미리보기 루프 ─────────────────
.qt.is-demo::before {
transform-origin: left center; // 규칙8: 채움 방향 — 옅은 배경이 왼쪽 변에서 scaleX 로 차오른다
animation-name: qtFill;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.qt.is-demo .qt__label {
animation-name: qtLabel;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.qt.is-demo .qt__body {
animation-name: qtBody;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 배경: 0~18% 좌→우 채움(입장이 프레임당 더 넓게), 74~96% 우에서 좌로 흘러 나가감(컷 아님)
@keyframes qtFill {
0% { transform: scaleX(0); }
18%, 74% { transform: scaleX(1); }
96%, 100% { transform: scaleX(0); }
}
// 라벨: 채움이 절반 지날 때 컷 — 채우는 프레임과 겹쳐 입장이 최대 변화가 되게
@keyframes qtLabel {
0%, 7% { opacity: 0; }
8%, 68% { opacity: 1; }
72%, 100% { opacity: 0; }
}
// 문장: 채움을 따라 슬라이드하며 드러남
@keyframes qtBody {
0%, 10% { opacity: 0; transform: translateX(-10px); }
26%, 70% { opacity: 1; transform: translateX(0); }
76%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.qt.is-demo::before,
.qt.is-demo .qt__label,
.qt.is-demo .qt__body {
animation: none !important;
}
}
@media (max-width: 340px) {
.qt { padding: $sp-3 $sp-4; }
.qt__body { font-size: 13px; }
}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);
}
.qt {
box-sizing: border-box;
position: relative;
width: min(320px, 92vw);
margin: 0;
padding: 16px 20px;
border: 1px solid rgba(185, 160, 255, 0.55);
border-radius: 16px;
overflow: hidden;
font-family: inherit;
isolation: isolate;
}
.qt * {
box-sizing: border-box;
}
.qt::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
background: #b9a0ff;
opacity: 0.3;
}
.qt__label,
.qt__body {
position: relative;
z-index: 1;
margin: 0;
display: block;
}
.qt__label {
font-size: 11px;
font-weight: 700;
line-height: 1.4;
letter-spacing: 0.08em;
color: rgba(85, 55, 180, 0.9);
transition-property: color;
transition-duration: 140ms;
transition-delay: 80ms;
}
.qt__body {
margin-top: 8px;
font-size: 14px;
font-weight: 600;
line-height: 1.6;
color: #17141a;
}
.qt:hover .qt__label {
color: rgb(60, 30, 150);
}
.qt.is-demo::before {
transform-origin: left center;
animation-name: qtFill;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.qt.is-demo .qt__label {
animation-name: qtLabel;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.qt.is-demo .qt__body {
animation-name: qtBody;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes qtFill {
0% {
transform: scaleX(0);
}
18%, 74% {
transform: scaleX(1);
}
96%, 100% {
transform: scaleX(0);
}
}
@keyframes qtLabel {
0%, 7% {
opacity: 0;
}
8%, 68% {
opacity: 1;
}
72%, 100% {
opacity: 0;
}
}
@keyframes qtBody {
0%, 10% {
opacity: 0;
transform: translateX(-10px);
}
26%, 70% {
opacity: 1;
transform: translateX(0);
}
76%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.qt.is-demo::before,
.qt.is-demo .qt__label,
.qt.is-demo .qt__body {
animation: none !important;
}
}
@media (max-width: 340px) {
.qt {
padding: 12px 16px;
}
.qt__body {
font-size: 13px;
}
}// QuoteTintFill — vanilla 판(run/282/demo/04-quote-tint-fill) 의 React 포트.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./QuoteTintFill.module.scss";
export type QuoteTintFillProps = {
label?: string;
line1?: string;
line2?: string;
groupLabel?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function QuoteTintFill({
label = "작업 요점",
line1 = "값을 바꾸기 전에",
line2 = "먼저 화면에 있는 값을 읽어라.",
groupLabel = "배경이 차오르는 인용 박스",
demo = true,
duration = 2000,
easing = "ease-out",
color = "#b9a0ff",
}: QuoteTintFillProps) {
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onEnter() {
setLive(false);
}
const cls = [styles.qt, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<figure className={cls} style={vars} aria-label={groupLabel} onMouseEnter={onEnter}>
<figcaption className={styles.label}>{label}</figcaption>
<blockquote className={styles.body}>{line1}<br />{line2}</blockquote>
</figure>
);
}04 · Quote box filling left to right — Measured area 21.95% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
In real use, resting a hand on the box deepens the label color one beat later via transition-delay.
// The filling background — sits a layer below the content (z-index) and scales from the left
.qt::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
background: $color; // lilac
opacity: .3;
}
.qt.is-demo::before {
transform-origin: left center;
animation-name: qtFill;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes qtFill {
0% { transform: scaleX(0); }
18%, 74% { transform: scaleX(1); }
96%, 100% { transform: scaleX(0); } // slower than the entrance — the exit must not win the poster
}
05Quote framed by marks sliding inward
What happens on screen
For interviews and research reports. Two oversized serif glyphs set with ::before and ::after slide in from beyond the edges and tighten around the sentence, which lifts once at mid-loop (accumulated area 4.822%).

<main class="stage">
<figure class="qs is-demo" aria-label="Quote framed by marks sliding inward">
<blockquote class="qs__body">Choose the wrong question,<br>and even a good answer is useless.</blockquote>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// 인터뷰용 인용구 — ::before/::after 로 얹은 커다란 따옴표 글리프가 바깥에서 안으로 미끄러져
// 들어와 문장을 틀 안에 가두고, 문장은 가운데서 한 번 떠오른다.
// 따옴표는 아이콘이 아니라 타이포그래피 글리프(세리프) — 문장보다 크게 벌려 두는 게 핵심.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-paper;
@include stage($stage-ink);
.qs {
box-sizing: border-box;
width: min(320px, 92vw);
margin: 0;
padding: $sp-6 $sp-8;
font-family: inherit;
}
.qs * { box-sizing: border-box; }
.qs__body {
position: relative;
z-index: 1;
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.7;
text-align: center;
color: $color;
}
// 양 끝 따옴표 — 문장보다 위·아래로 크게 벌어져 있는 글리프
.qs__body::before,
.qs__body::after {
position: absolute;
z-index: -1;
font-family: Georgia, "Times New Roman", serif;
font-size: 52px;
font-weight: 700;
line-height: 1;
color: $subject-lilac;
opacity: .9;
pointer-events: none;
}
.qs__body::before { content: "\201C"; top: -28px; left: -20px; }
.qs__body::after { content: "\201D"; bottom: -28px; right: -20px; }
// ── 미리보기 루프 ─────────────────
.qs.is-demo .qs__body {
animation-name: qsLift;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.qs.is-demo .qs__body::before {
animation-name: qsMarkBefore;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.qs.is-demo .qs__body::after {
animation-name: qsMarkAfter;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 문장·따옴표 모두 opacity 램프를 쓰지 않는다 — 텍스트 페이드의 중간값이 poster 에
// 반투명 유령으로 남는다(237 계열 함정). 문장은 translateY 로, 따옴표는 슬라이드+스케일로
// 처음부터 불투명하게 움직인다. 복귀: 문장은 되돌아 내려가고, 따옴표는 바깥으로 슬라이드.
@keyframes qsLift {
0%, 20% { opacity: 1; transform: translateY(10px); }
32%, 68% { opacity: 1; transform: translateY(0); }
74%, 100% { opacity: 1; transform: translateY(10px); }
}
// 여는 따옴표: 왼바깥 → 제자리 → 다시 바깥으로(불투명 유지)
@keyframes qsMarkBefore {
0% { opacity: .9; transform: translateX(-30px) scale(.7); }
28%, 68% { opacity: .9; transform: translateX(0) scale(1); }
92%, 100% { opacity: .9; transform: translateX(-30px) scale(.7); }
}
// 닫는 따옴표: 오른바깥 → 제자리 → 다시 바깥으로(같은 시간, 반대 방향 — 두 글리프가
// 동시에 움직이는 입장 프레임이 최대 변화)
@keyframes qsMarkAfter {
0% { opacity: .9; transform: translateX(30px) scale(.7); }
28%, 68% { opacity: .9; transform: translateX(0) scale(1); }
92%, 100% { opacity: .9; transform: translateX(30px) scale(.7); }
}
@media (prefers-reduced-motion: reduce) {
.qs.is-demo .qs__body,
.qs.is-demo .qs__body::before,
.qs.is-demo .qs__body::after {
animation: none !important;
}
}
@media (max-width: 340px) {
.qs { padding: $sp-5 $sp-6; }
.qs__body { font-size: 14px; }
.qs__body::before, .qs__body::after { font-size: 44px; }
.qs__body::before { top: -24px; left: -16px; }
.qs__body::after { bottom: -24px; right: -16px; }
}@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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.qs {
box-sizing: border-box;
width: min(320px, 92vw);
margin: 0;
padding: 24px 32px;
font-family: inherit;
}
.qs * {
box-sizing: border-box;
}
.qs__body {
position: relative;
z-index: 1;
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.7;
text-align: center;
color: #ffffff;
}
.qs__body::before,
.qs__body::after {
position: absolute;
z-index: -1;
font-family: Georgia, "Times New Roman", serif;
font-size: 52px;
font-weight: 700;
line-height: 1;
color: #b9a0ff;
opacity: 0.9;
pointer-events: none;
}
.qs__body::before {
content: "“";
top: -28px;
left: -20px;
}
.qs__body::after {
content: "”";
bottom: -28px;
right: -20px;
}
.qs.is-demo .qs__body {
animation-name: qsLift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.qs.is-demo .qs__body::before {
animation-name: qsMarkBefore;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.qs.is-demo .qs__body::after {
animation-name: qsMarkAfter;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes qsLift {
0%, 20% {
opacity: 1;
transform: translateY(10px);
}
32%, 68% {
opacity: 1;
transform: translateY(0);
}
74%, 100% {
opacity: 1;
transform: translateY(10px);
}
}
@keyframes qsMarkBefore {
0% {
opacity: 0.9;
transform: translateX(-30px) scale(0.7);
}
28%, 68% {
opacity: 0.9;
transform: translateX(0) scale(1);
}
92%, 100% {
opacity: 0.9;
transform: translateX(-30px) scale(0.7);
}
}
@keyframes qsMarkAfter {
0% {
opacity: 0.9;
transform: translateX(30px) scale(0.7);
}
28%, 68% {
opacity: 0.9;
transform: translateX(0) scale(1);
}
92%, 100% {
opacity: 0.9;
transform: translateX(30px) scale(0.7);
}
}
@media (prefers-reduced-motion: reduce) {
.qs.is-demo .qs__body,
.qs.is-demo .qs__body::before,
.qs.is-demo .qs__body::after {
animation: none !important;
}
}
@media (max-width: 340px) {
.qs {
padding: 20px 24px;
}
.qs__body {
font-size: 14px;
}
.qs__body::before, .qs__body::after {
font-size: 44px;
}
.qs__body::before {
top: -24px;
left: -16px;
}
.qs__body::after {
bottom: -24px;
right: -16px;
}
}// QuoteBracketSlide — vanilla 판(run/282/demo/05-quote-bracket-slide) 의 React 포트.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./QuoteBracketSlide.module.scss";
export type QuoteBracketSlideProps = {
line1?: string;
line2?: string;
groupLabel?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function QuoteBracketSlide({
line1 = "물음을 잘못 골랐다면",
line2 = "좋은 답도 소용이 없다.",
groupLabel = "양 끝 따옴표가 닫히는 인용구",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffffff",
}: QuoteBracketSlideProps) {
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onEnter() {
setLive(false);
}
const cls = [styles.qs, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<figure className={cls} style={vars} aria-label={groupLabel} onMouseEnter={onEnter}>
<blockquote className={styles.body}>{line1}<br />{line2}</blockquote>
</figure>
);
}05 · Quote framed by marks sliding inward — Measured area 4.822% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The marks are typography, not icons, so font-family: Georgia gives them their display-serif shape, and letting them hang well above and below the text is the whole point of this box.
// The end marks — absolutely positioned glyphs tightening inward, kept opaque (no fade)
.qs__body::before,
.qs__body::after {
position: absolute;
z-index: -1;
font-family: Georgia, "Times New Roman", serif;
font-size: 52px;
color: $subject-lilac;
opacity: .9;
}
.qs__body::before { content: "\201C"; top: -28px; left: -20px; }
.qs__body::after { content: "\201D"; bottom: -28px; right: -20px; }
@keyframes qsMarkBefore {
0% { opacity: .9; transform: translateX(-30px) scale(.7); }
28%, 68% { opacity: .9; transform: translateX(0) scale(1); }
92%, 100% { opacity: .9; transform: translateX(-30px) scale(.7); }
}
// The closing mark (qsMarkAfter) mirrors the direction at +30px — both glyphs move in the same frames
06Source line revealing the speaker a beat late
What moves
For editorial interviews and customer blurbs. As the quote settles, an initial disc pops with scale, the name and role rise after it, and an underline fills left to right with scaleX (accumulated area 3.433%, intensity 111.5).

<main class="stage">
<figure class="qc is-demo" aria-label="Source line revealing the speaker late">
<blockquote class="qc__body">Understanding is subtraction, not addition.</blockquote>
<figcaption class="qc__src">
<span class="qc__disc" aria-hidden="true">P</span>
<span class="qc__who">
<span class="qc__name">Print editor</span>
<span class="qc__role">Monthly magazine interview</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 · Source line revealing the speaker a beat late — Measured area 3.441% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Three of the four elements — quote, disc, and name — enter inside the same 6–24% window so several move in one frame, while the underline waits until 28%; why that placement matters is the first story in the trap section.
// The source row — disc (left) and name·role (right) joined horizontally, underline at the bottom edge
.qc__src {
display: flex;
align-items: center;
gap: $sp-3;
padding-bottom: $sp-2;
position: relative;
}
.qc__src::after { // underline — fills from a left origin
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;
}
@keyframes qcDisc { // initial disc — pops overlapping the quote (overshoot)
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; }
}
07Quote as an ink block flipping in
What you see
When you want weight — portfolio statements, product philosophy. An ink block wipes in from the left with clip-path inset, and the white sentence flips on top of it in a single steps(1, end) cut — mixing steps timing and clip-path in one @keyframes silently kills interpolation, so the block (wipe) and the sentence (cut) are two separate animations.

<main class="stage">
<figure class="qi is-demo" aria-label="Quote flipping into an ink block">
<blockquote class="qi__body">
<span class="qi__l1">I don't fix yesterday's screen;</span>
<span class="qi__l2">I make tomorrow's screen better.</span>
</blockquote>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// 포트폴리오 선언문용 인용구 — 잉크색 블록이 clip-path inset 으로 좌에서 우로 닦이며 나타나고,
// 그 위의 흰 문장이 steps(1, end) 컷으로 한 번에 뒤집힌다(잉크 배경 위 흰 글자 — 대비 18.24:1).
// 266-09 함정: steps 타이밍과 clip-path 를 한 keyframes 에 섞으면 보간이 죽는다 — 블록(ease 위프)과
// 문장(steps 컷)을 애니메이션 둘로 분리했다. 복귀: 문장 컷 → 블록 위프로 우에서 좌로 닦아 나감(입장 16% vs 퇴장 18%).
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.qi {
box-sizing: border-box;
width: min(320px, 92vw);
margin: 0;
padding: $sp-5 $sp-6;
border-radius: $r-card;
background: $color;
color: $stage-paper;
font-family: inherit;
}
.qi * { box-sizing: border-box; }
.qi__body {
margin: 0;
display: grid;
gap: $sp-2;
}
.qi__l1,
.qi__l2 {
display: block;
font-size: 14px;
font-weight: 700;
line-height: 1.6;
color: inherit;
}
// ── 미리보기 루프 ─────────────────
.qi.is-demo {
animation-name: qiWipe;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.qi.is-demo .qi__l1,
.qi.is-demo .qi__l2 {
animation-name: qiCut1;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.qi.is-demo .qi__l2 { animation-name: qiCut2; }
// 잉크 블록: 0~16% 좌→우 위프로 닦여 나타남(빠르게 — 문장 컷과 겹치는 프레임이 최대 변화)
@keyframes qiWipe {
0% { clip-path: inset(0 100% 0 0 round 16px); }
16%, 74% { clip-path: inset(0 0 0 0 round 16px); }
92%, 100% { clip-path: inset(0 100% 0 0 round 16px); }
}
// 문장 1: 블록이 다 찬 직후 한 프레임 컷
@keyframes qiCut1 {
0%, 17% { opacity: 0; }
21%, 70% { opacity: 1; }
74%, 100% { opacity: 0; }
}
// 문장 2: 한 프레임 뒤
@keyframes qiCut2 {
0%, 19% { opacity: 0; }
23%, 68% { opacity: 1; }
72%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.qi.is-demo,
.qi.is-demo .qi__l1,
.qi.is-demo .qi__l2 {
animation: none !important;
}
}
@media (max-width: 340px) {
.qi { padding: $sp-4 $sp-5; }
.qi__l1, .qi__l2 { font-size: 13px; }
}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);
}
.qi {
box-sizing: border-box;
width: min(320px, 92vw);
margin: 0;
padding: 20px 24px;
border-radius: 16px;
background: #17141a;
color: #ffffff;
font-family: inherit;
}
.qi * {
box-sizing: border-box;
}
.qi__body {
margin: 0;
display: grid;
gap: 8px;
}
.qi__l1,
.qi__l2 {
display: block;
font-size: 14px;
font-weight: 700;
line-height: 1.6;
color: inherit;
}
.qi.is-demo {
animation-name: qiWipe;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.qi.is-demo .qi__l1,
.qi.is-demo .qi__l2 {
animation-name: qiCut1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.qi.is-demo .qi__l2 {
animation-name: qiCut2;
}
@keyframes qiWipe {
0% {
clip-path: inset(0 100% 0 0 round 16px);
}
16%, 74% {
clip-path: inset(0 0 0 0 round 16px);
}
92%, 100% {
clip-path: inset(0 100% 0 0 round 16px);
}
}
@keyframes qiCut1 {
0%, 17% {
opacity: 0;
}
21%, 70% {
opacity: 1;
}
74%, 100% {
opacity: 0;
}
}
@keyframes qiCut2 {
0%, 19% {
opacity: 0;
}
23%, 68% {
opacity: 1;
}
72%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.qi.is-demo,
.qi.is-demo .qi__l1,
.qi.is-demo .qi__l2 {
animation: none !important;
}
}
@media (max-width: 340px) {
.qi {
padding: 16px 20px;
}
.qi__l1, .qi__l2 {
font-size: 13px;
}
}// QuoteInkInvert — vanilla 판(run/282/demo/07-quote-ink-invert) 의 React 포트.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./QuoteInkInvert.module.scss";
export type QuoteInkInvertProps = {
line1?: string;
line2?: string;
groupLabel?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function QuoteInkInvert({
line1 = "나는 어제의 화면을 고치지 않고,",
line2 = "내일의 화면을 더 잘 만든다.",
groupLabel = "잉크 블록으로 뒤집히는 인용구",
demo = true,
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: QuoteInkInvertProps) {
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onEnter() {
setLive(false);
}
const cls = [styles.qi, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<figure className={cls} style={vars} aria-label={groupLabel} onMouseEnter={onEnter}>
<blockquote className={styles.body}>
<span className={styles.l1}>{line1}</span>
<span className={styles.l2}>{line2}</span>
</blockquote>
</figure>
);
}07 · Quote as an ink block flipping in — Measured area 20.317% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
White on ink measures 18.24:1; the area is 20.317% and the intensity 203.3 is the highest of the nine.
// The ink wipe and the text cut are separate animations — steps and clip-path don't interpolate together
.qi.is-demo {
animation-name: qiWipe; // eased wipe
animation-timing-function: $ease-out;
}
.qi.is-demo .qi__l1,
.qi.is-demo .qi__l2 {
animation-name: qiCut1;
animation-timing-function: steps(1, end); // single-frame cut
animation-iteration-count: infinite;
}
@keyframes qiWipe {
0% { clip-path: inset(0 100% 0 0 round 16px); }
16%, 74% { clip-path: inset(0 0 0 0 round 16px); }
92%, 100% { clip-path: inset(0 100% 0 0 round 16px); }
}
@keyframes qiCut1 {
0%, 17% { opacity: 0; }
21%, 70% { opacity: 1; }
74%, 100% { opacity: 0; }
}
08Quote with a giant mark ghosting behind
What happens on screen
For essay openings and book-cover lines. A 120px quotation glyph rises slowly behind the text at 22% lilac to become the background, while the foreground sentence stays crisp one layer up with z-index (accumulated area 4.192%).

<main class="stage">
<figure class="qw is-demo" aria-label="Quote with a giant mark behind">
<span class="qw__mark" aria-hidden="true">“</span>
<blockquote class="qw__body">A slow sentence<br>beats a fast one.</blockquote>
<figcaption class="qw__cite">— Essay opening</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// 에세이 리드용 인용구 — 등 뒤로 커다란 따옴표 글리프가 서서히 떠올라(scale+opacity) 배경 무늬가 되고,
// 전경 문장은 z-index 로 한 층 위에 또렷하게 남는다. 워터마크는 아주 연하게(라일락 22%) —
// 글자가 아니라 '자리'를 만드는 역할이므로 대비를 낮춘다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-paper);
.qw {
box-sizing: border-box;
position: relative;
width: min(280px, 92vw);
margin: 0;
padding: $sp-8 $sp-4;
display: grid;
justify-items: center;
gap: $sp-3;
text-align: center;
font-family: inherit;
}
.qw * { box-sizing: border-box; }
// 거대 따옴표 — 문장 뒤 한 층 아래
.qw__mark {
position: absolute;
z-index: 0;
top: -8px;
left: 50%;
translate: -50% 0;
font-family: Georgia, "Times New Roman", serif;
font-size: 120px;
font-weight: 700;
line-height: 1;
color: $subject-lilac;
opacity: .22;
pointer-events: none;
}
.qw__body {
position: relative;
z-index: 1;
margin: 0;
font-size: 17px;
font-weight: 800;
line-height: 1.6;
color: $color;
}
.qw__cite {
position: relative;
z-index: 1;
margin: 0;
font-size: 11px;
font-weight: 500;
line-height: 1.4;
color: rgba(23, 20, 26, .6);
}
// ── 미리보기 루프 ─────────────────
.qw.is-demo .qw__mark {
animation-name: qwMark;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.qw.is-demo .qw__body {
animation-name: qwBody;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.qw.is-demo .qw__cite {
animation-name: qwCite;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
// 워터마크: 0~46% 천천히 확장하며 떠오름 — 문장 컷 등장과 겹치는 초반 프레임이 최대 변화.
// 문장·출처는 opacity 램프가 아니라 steps 컷으로 등장한 뒤 translateY 로 떠오른다
// (키프레임 안 animation-timing-function 으로 속성 구간별 타이밍을 나눈다 — 텍스트 페이드의
// 중간값이 poster 에 유령으로 남는다, 237 계열 함정).
// 퇴장은 셋을 서로 먼 구간에 12%+ 페이드로 — 통째로 사라지는 컷 프레임이 입장을 이기고
// 빈 화면이 poster 가 되는 것을 막는다(248-09 함정).
@keyframes qwMark {
0% { opacity: 0; transform: scale(.6); }
46%, 70% { opacity: .22; transform: scale(1); }
92%, 100% { opacity: 0; transform: scale(.8); }
}
@keyframes qwBody {
0% { opacity: 0; transform: translateY(10px); animation-timing-function: steps(1, end); }
10% { opacity: 1; transform: translateY(10px); animation-timing-function: $easing; }
30%, 60% { opacity: 1; transform: translateY(0); }
74%, 100% { opacity: 0; }
}
@keyframes qwCite {
0% { opacity: 0; transform: translateY(8px); animation-timing-function: steps(1, end); }
16% { opacity: 1; transform: translateY(8px); animation-timing-function: $easing; }
36%, 58% { opacity: 1; transform: translateY(0); }
70%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.qw.is-demo .qw__mark,
.qw.is-demo .qw__body,
.qw.is-demo .qw__cite {
animation: none !important;
}
}
@media (max-width: 340px) {
.qw { padding: $sp-6 $sp-3; }
.qw__body { font-size: 16px; }
.qw__mark { font-size: 104px; }
}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);
}
.qw {
box-sizing: border-box;
position: relative;
width: min(280px, 92vw);
margin: 0;
padding: 32px 16px;
display: grid;
justify-items: center;
gap: 12px;
text-align: center;
font-family: inherit;
}
.qw * {
box-sizing: border-box;
}
.qw__mark {
position: absolute;
z-index: 0;
top: -8px;
left: 50%;
translate: -50% 0;
font-family: Georgia, "Times New Roman", serif;
font-size: 120px;
font-weight: 700;
line-height: 1;
color: #b9a0ff;
opacity: 0.22;
pointer-events: none;
}
.qw__body {
position: relative;
z-index: 1;
margin: 0;
font-size: 17px;
font-weight: 800;
line-height: 1.6;
color: #17141a;
}
.qw__cite {
position: relative;
z-index: 1;
margin: 0;
font-size: 11px;
font-weight: 500;
line-height: 1.4;
color: rgba(23, 20, 26, 0.6);
}
.qw.is-demo .qw__mark {
animation-name: qwMark;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.qw.is-demo .qw__body {
animation-name: qwBody;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.qw.is-demo .qw__cite {
animation-name: qwCite;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes qwMark {
0% {
opacity: 0;
transform: scale(0.6);
}
46%, 70% {
opacity: 0.22;
transform: scale(1);
}
92%, 100% {
opacity: 0;
transform: scale(0.8);
}
}
@keyframes qwBody {
0% {
opacity: 0;
transform: translateY(10px);
animation-timing-function: steps(1, end);
}
10% {
opacity: 1;
transform: translateY(10px);
animation-timing-function: ease-out;
}
30%, 60% {
opacity: 1;
transform: translateY(0);
}
74%, 100% {
opacity: 0;
}
}
@keyframes qwCite {
0% {
opacity: 0;
transform: translateY(8px);
animation-timing-function: steps(1, end);
}
16% {
opacity: 1;
transform: translateY(8px);
animation-timing-function: ease-out;
}
36%, 58% {
opacity: 1;
transform: translateY(0);
}
70%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.qw.is-demo .qw__mark,
.qw.is-demo .qw__body,
.qw.is-demo .qw__cite {
animation: none !important;
}
}
@media (max-width: 340px) {
.qw {
padding: 24px 12px;
}
.qw__body {
font-size: 16px;
}
.qw__mark {
font-size: 104px;
}
}// QuoteWatermarkFade — vanilla 판(run/282/demo/08-quote-watermark-fade) 의 React 포트.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./QuoteWatermarkFade.module.scss";
export type QuoteWatermarkFadeProps = {
line1?: string;
line2?: string;
cite?: string;
groupLabel?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function QuoteWatermarkFade({
line1 = "느린 문장이",
line2 = "빠른 문장을 이긴다.",
cite = "— 에세이 여는 글",
groupLabel = "뒤에 큰 따옴표가 떠오르는 인용구",
demo = true,
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: QuoteWatermarkFadeProps) {
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onEnter() {
setLive(false);
}
const cls = [styles.qw, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<figure className={cls} style={vars} aria-label={groupLabel} onMouseEnter={onEnter}>
<span className={styles.mark} aria-hidden="true">“</span>
<blockquote className={styles.body}>{line1}<br />{line2}</blockquote>
<figcaption className={styles.cite}>{cite}</figcaption>
</figure>
);
}08 · Quote with a giant mark ghosting behind — Measured area 4.192% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The design move is cutting the sentence in early, at 10%, so it overlaps the watermark's slow rise, which takes until 46% of the loop to finish.
// The giant mark — rises a layer below the text (z-index: 0)
.qw__mark {
position: absolute;
z-index: 0;
font-family: Georgia, "Times New Roman", serif;
font-size: 120px;
color: $subject-lilac;
opacity: .22;
}
// The sentence cuts in with steps, then rises — timing functions inside keyframes split per segment
@keyframes qwBody {
0% { opacity: 0; transform: translateY(10px); animation-timing-function: steps(1, end); }
10% { opacity: 1; transform: translateY(10px); animation-timing-function: $easing; }
30%, 60% { opacity: 1; transform: translateY(0); }
74%, 100% { opacity: 0; }
}
09Quote that swaps lines on hover
What moves
The rotating line under a landing hero. Three quotes are stacked in one cell with grid-area: 1 / 1 and only one shows at a time; in the grid preview a steps(1, end) cut swaps the sentence at exactly 33% and 67% (its 1.697% area is the quietest of the nine, but an intensity of 96.7 keeps the swap crisp).

<main class="stage">
<figure class="qx is-demo" id="qx" aria-label="Quote that swaps on hover">
<svg class="qx__mark" id="qx-mark" viewBox="0 0 256 256" width="20" height="20" fill="currentColor" aria-hidden="true"><g><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-8-16m0 80h-60V72h60Z"/></g></svg>
<div class="qx__swap" aria-live="polite">
<p class="qx__line is-on" aria-hidden="false">Code is written to be read.</p>
<p class="qx__line" aria-hidden="true">Screens should respect the people waiting.</p>
<p class="qx__line" aria-hidden="true">Numbers keep their digits steady.</p>
</div>
<figcaption class="qx__foot">
<span class="qx__dots" aria-hidden="true">
<span class="qx__dot is-on"></span>
<span class="qx__dot"></span>
<span class="qx__dot"></span>
</span>
<button class="qx__next" id="qx-next" type="button">Next quote</button>
</figcaption>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// 랜딩 히어로 아래 도는 문구 — 세 인용을 같은 칸에 겹쳐 두고(grid-area 1/1) 한 번에 하나만 보이게 한다.
// 격자 미리보기 루프는 steps(1, end) 컷으로 세 문장을 2초에 세 번 바꾼다(33%·67% 지점).
// 실사용은 hover 와 버튼이 JS 로 is-on 을 옮긴다 — 크로스페이드가 아니라 짧은 오퍼시티 전환.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-ink);
.qx {
box-sizing: border-box;
width: min(300px, 92vw);
margin: 0;
padding: $sp-4 $sp-5;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
display: grid;
gap: $sp-3;
font-family: inherit;
}
.qx * { box-sizing: border-box; }
.qx__mark { color: $subject-blue; }
.qx__swap {
display: grid;
min-height: 48px;
}
// 세 문장을 한 칸에 겹친다 — 높이는 제일 긴 문장이 결정한다
.qx__line {
grid-area: 1 / 1;
margin: 0;
font-size: 14px;
font-weight: 700;
line-height: 1.7;
color: $subject-ink;
opacity: 0;
transition-property: opacity;
transition-duration: 140ms;
}
.qx__line.is-on { opacity: 1; }
.qx__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-3;
}
.qx__dots {
display: flex;
gap: $sp-2;
}
.qx__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, .25);
transition-property: transform, background-color;
transition-duration: 140ms;
}
.qx__dot.is-on {
background: $subject-ink;
transform: scale(1.15);
}
.qx__next {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-4;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
cursor: pointer;
}
.qx__next:focus-visible {
outline: 2px solid $subject-blue;
outline-offset: 2px;
}
// 실사용: 교체를 알리는 따옴표 튕김(240ms 한 번)
.qx__mark.is-bump {
animation-name: qxBumpOnce;
animation-duration: 240ms;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes qxBumpOnce {
0% { transform: translateY(0); }
40% { transform: translateY(-5px); }
100% { transform: translateY(0); }
}
// ── 미리보기 루프(정적 규칙 먼저, 그 위에 덧씌움) ─────────────────
.qx.is-demo .qx__line {
transition: none;
}
.qx.is-demo .qx__line { animation-timing-function: steps(1, end); animation-duration: $duration; animation-iteration-count: infinite; }
.qx.is-demo .qx__line:nth-child(1) { animation-name: qxLine1; }
.qx.is-demo .qx__line:nth-child(2) { animation-name: qxLine2; }
.qx.is-demo .qx__line:nth-child(3) { animation-name: qxLine3; }
.qx.is-demo .qx__dot { animation-timing-function: steps(1, end); animation-duration: $duration; animation-iteration-count: infinite; }
.qx.is-demo .qx__dot:nth-child(1) { animation-name: qxDot1; }
.qx.is-demo .qx__dot:nth-child(2) { animation-name: qxDot2; }
.qx.is-demo .qx__dot:nth-child(3) { animation-name: qxDot3; }
.qx.is-demo .qx__mark {
animation-name: qxMarkLoop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 세 문장 — 33%·67% 정각에 동시 교체(중간 반투명 프레임도, 한 프레임 빈 칸도 없다)
@keyframes qxLine1 {
0% { opacity: 1; }
33%, 100% { opacity: 0; }
}
@keyframes qxLine2 {
0% { opacity: 0; }
33% { opacity: 1; }
67%, 100% { opacity: 0; }
}
@keyframes qxLine3 {
0%, 66% { opacity: 0; }
67%, 100% { opacity: 1; }
}
// 점 표시 — 문장과 같은 지점에 컷(크기와 색을 함께 바꾼다 — 색을 안 넣으면 어두운 점이 그대로 남는다)
@keyframes qxDot1 {
0% { transform: scale(1.15); background-color: rgba(23, 20, 26, 1); }
33%, 100% { transform: scale(1); background-color: rgba(23, 20, 26, .25); }
}
@keyframes qxDot2 {
0%, 33% { transform: scale(1); background-color: rgba(23, 20, 26, .25); }
33.01% { transform: scale(1.15); background-color: rgba(23, 20, 26, 1); }
67%, 100% { transform: scale(1); background-color: rgba(23, 20, 26, .25); }
}
@keyframes qxDot3 {
0%, 66% { transform: scale(1); background-color: rgba(23, 20, 26, .25); }
67%, 100% { transform: scale(1.15); background-color: rgba(23, 20, 26, 1); }
}
// 따옴표 — 컷 직후마다 짧게 튄다(컷만 있으면 움직인 프레임이 모자란다 — 252 함정)
@keyframes qxMarkLoop {
0% { transform: translateY(0); }
3% { transform: translateY(-5px); }
7%, 34% { transform: translateY(0); }
37% { transform: translateY(-5px); }
41%, 68% { transform: translateY(0); }
71% { transform: translateY(-5px); }
75%, 100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.qx.is-demo .qx__line,
.qx.is-demo .qx__dot,
.qx.is-demo .qx__mark {
animation: none !important;
}
.qx__line, .qx__dot { transition: none !important; }
}
@media (max-width: 340px) {
.qx { padding: $sp-3 $sp-4; }
.qx__line { font-size: 13px; }
.qx__swap { min-height: 44px; }
}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);
}
.qx {
box-sizing: border-box;
width: min(300px, 92vw);
margin: 0;
padding: 16px 20px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
display: grid;
gap: 12px;
font-family: inherit;
}
.qx * {
box-sizing: border-box;
}
.qx__mark {
color: #2f6df6;
}
.qx__swap {
display: grid;
min-height: 48px;
}
.qx__line {
grid-area: 1/1;
margin: 0;
font-size: 14px;
font-weight: 700;
line-height: 1.7;
color: #17141a;
opacity: 0;
transition-property: opacity;
transition-duration: 140ms;
}
.qx__line.is-on {
opacity: 1;
}
.qx__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.qx__dots {
display: flex;
gap: 8px;
}
.qx__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.25);
transition-property: transform, background-color;
transition-duration: 140ms;
}
.qx__dot.is-on {
background: #17141a;
transform: scale(1.15);
}
.qx__next {
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
cursor: pointer;
}
.qx__next:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.qx__mark.is-bump {
animation-name: qxBumpOnce;
animation-duration: 240ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
}
@keyframes qxBumpOnce {
0% {
transform: translateY(0);
}
40% {
transform: translateY(-5px);
}
100% {
transform: translateY(0);
}
}
.qx.is-demo .qx__line {
transition: none;
}
.qx.is-demo .qx__line {
animation-timing-function: steps(1, end);
animation-duration: 2s;
animation-iteration-count: infinite;
}
.qx.is-demo .qx__line:nth-child(1) {
animation-name: qxLine1;
}
.qx.is-demo .qx__line:nth-child(2) {
animation-name: qxLine2;
}
.qx.is-demo .qx__line:nth-child(3) {
animation-name: qxLine3;
}
.qx.is-demo .qx__dot {
animation-timing-function: steps(1, end);
animation-duration: 2s;
animation-iteration-count: infinite;
}
.qx.is-demo .qx__dot:nth-child(1) {
animation-name: qxDot1;
}
.qx.is-demo .qx__dot:nth-child(2) {
animation-name: qxDot2;
}
.qx.is-demo .qx__dot:nth-child(3) {
animation-name: qxDot3;
}
.qx.is-demo .qx__mark {
animation-name: qxMarkLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes qxLine1 {
0% {
opacity: 1;
}
33%, 100% {
opacity: 0;
}
}
@keyframes qxLine2 {
0% {
opacity: 0;
}
33% {
opacity: 1;
}
67%, 100% {
opacity: 0;
}
}
@keyframes qxLine3 {
0%, 66% {
opacity: 0;
}
67%, 100% {
opacity: 1;
}
}
@keyframes qxDot1 {
0% {
transform: scale(1.15);
background-color: rgb(23, 20, 26);
}
33%, 100% {
transform: scale(1);
background-color: rgba(23, 20, 26, 0.25);
}
}
@keyframes qxDot2 {
0%, 33% {
transform: scale(1);
background-color: rgba(23, 20, 26, 0.25);
}
33.01% {
transform: scale(1.15);
background-color: rgb(23, 20, 26);
}
67%, 100% {
transform: scale(1);
background-color: rgba(23, 20, 26, 0.25);
}
}
@keyframes qxDot3 {
0%, 66% {
transform: scale(1);
background-color: rgba(23, 20, 26, 0.25);
}
67%, 100% {
transform: scale(1.15);
background-color: rgb(23, 20, 26);
}
}
@keyframes qxMarkLoop {
0% {
transform: translateY(0);
}
3% {
transform: translateY(-5px);
}
7%, 34% {
transform: translateY(0);
}
37% {
transform: translateY(-5px);
}
41%, 68% {
transform: translateY(0);
}
71% {
transform: translateY(-5px);
}
75%, 100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.qx.is-demo .qx__line,
.qx.is-demo .qx__dot,
.qx.is-demo .qx__mark {
animation: none !important;
}
.qx__line, .qx__dot {
transition: none !important;
}
}
@media (max-width: 340px) {
.qx {
padding: 12px 16px;
}
.qx__line {
font-size: 13px;
}
.qx__swap {
min-height: 44px;
}
}// 손을 얹거나(hover) 버튼을 누르면 다음 인용으로 바뀐다 — 격자 루프의 steps 컷과 달리
// 실사용에서는 is-on 클래스가 움직이는 문장을 결정하고, 따옴표가 짧게 튀어 교체를 알린다.
var qxRoot = document.getElementById('qx');
var qxLines = qxRoot.querySelectorAll('.qx__line');
var qxDots = qxRoot.querySelectorAll('.qx__dot');
var qxMark = document.getElementById('qx-mark');
var qxIndex = 0;
function qxDraw() {
for (var i = 0; i < qxLines.length; i++) {
qxLines[i].classList.toggle('is-on', i === qxIndex);
qxLines[i].setAttribute('aria-hidden', i === qxIndex ? 'false' : 'true');
qxDots[i].classList.toggle('is-on', i === qxIndex);
}
qxMark.classList.remove('is-bump');
void qxMark.offsetWidth; // 리플로우를 한 번 걸어 튕김 애니메이션을 다시 시작하게 한다
qxMark.classList.add('is-bump');
}
function qxGo() {
qxIndex = (qxIndex + 1) % qxLines.length;
qxDraw();
}
// 카드에 손을 얹으면 격자 루프(.is-demo)를 뗀다 — 이때부터 클래스가 곧 상태다
qxRoot.addEventListener('mouseenter', function () {
qxRoot.classList.remove('is-demo');
qxGo();
});
// 버튼은 키보드 경로다 — 마우스 클릭(event.detail > 0)은 진입(hover)에서 이미 한 번
// 진행됐으므로 무시한다. 두 번 건너뛰는 것을 막는다.
document.getElementById('qx-next').addEventListener('click', function (event) {
if (event.detail > 0) return;
qxGo();
});// QuoteHoverSwap — vanilla 판(run/282/demo/09-quote-hover-swap) 의 React 포트.
import { useState } from "react";
import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react";
import styles from "./QuoteHoverSwap.module.scss";
export type QuoteLine = { id: string; text: string };
export type QuoteHoverSwapProps = {
lines?: QuoteLine[];
nextLabel?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_LINES: QuoteLine[] = [
{ id: "read", text: "코드는 읽히기 위해 쓴다." },
{ id: "wait", text: "화면은 기다리는 사람을 배려한다." },
{ id: "digits", text: "숫자는 자릿수를 지킨다." },
];
function QuotesMark({ bump }: { bump: boolean }) {
const cls = [styles.mark, bump ? styles.isBump : ""].filter(Boolean).join(" ");
return (
<svg className={cls} viewBox="0 0 256 256" width="20" height="20" fill="currentColor" aria-hidden="true"><g><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-8-16m0 80h-60V72h60Z"/></g></svg>
);
}
export default function QuoteHoverSwap({
lines = DEFAULT_LINES,
nextLabel = "다음 인용",
groupLabel = "손을 얹으면 바뀌는 인용구",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: QuoteHoverSwapProps) {
const [live, setLive] = useState(demo);
const [index, setIndex] = useState(0);
const [bump, setBump] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 다음 인용으로 — 교체를 알리는 따옴표 튕김을 한 번 되돌렸다 켠다
function applyNext(next: number) {
setIndex(next);
setBump(false);
setTimeout(() => setBump(true), 0);
}
function goNext() {
applyNext((index + 1) % lines.length);
}
// 카드에 손을 얹으면 격자 루프를 뗀다 — 이때부터 상태가 곧 화면이다
function onEnter() {
setLive(false);
applyNext((index + 1) % lines.length);
}
// 버튼은 키보드 경로다 — 마우스 클릭(detail > 0)은 진입(hover)에서 이미 한 번
// 진행됐으므로 무시한다. 두 번 건너뛰는 것을 막는다.
function onNextClick(event: ReactMouseEvent<HTMLButtonElement>) {
if (event.detail > 0) return;
goNext();
}
const cls = [styles.qx, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<figure className={cls} style={vars} aria-label={groupLabel} onMouseEnter={onEnter}>
<QuotesMark bump={bump} />
<div className={styles.swap} aria-live="polite">
{lines.map((line, i) => (
<p
key={line.id}
className={[styles.line, i === index ? styles.isOn : ""].filter(Boolean).join(" ")}
aria-hidden={i === index ? "false" : "true"}
>
{line.text}
</p>
))}
</div>
<figcaption className={styles.foot}>
<span className={styles.dots} aria-hidden="true">
{lines.map((line, i) => (
<span key={line.id} className={[styles.dot, i === index ? styles.isOn : ""].filter(Boolean).join(" ")}></span>
))}
</span>
<button className={styles.next} type="button" onClick={onNextClick}>{nextLabel}</button>
</figcaption>
</figure>
);
}09 · Quote that swaps lines on hover — Measured area 1.697% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Resting a hand on the card makes the script drop the preview loop and advance to the next line, while the button stays as the keyboard path.
// Three sentences stacked in one cell — the longest one decides the height
.qx__swap { display: grid; min-height: 48px; }
.qx__line {
grid-area: 1 / 1;
opacity: 0;
transition-property: opacity;
transition-duration: 140ms; // real swaps get a short opacity cross
}
.qx__line.is-on { opacity: 1; }
// The grid preview loop swaps by cut — both sentences change at exactly 33% (no overlap, no blank)
@keyframes qxLine1 {
0% { opacity: 1; }
33%, 100% { opacity: 0; }
}
@keyframes qxLine2 {
0% { opacity: 0; }
33% { opacity: 1; }
67%, 100% { opacity: 0; }
}
Where it breaks — the trap
The first thing that broke was the poster turning into an empty stage. The first version of 06 revealed its four elements one at a time, and the frame where the whole body text vanished in a single cut beat every entrance frame — the poster came out at 0.1% changed area, an empty yellow stage. Two fixes went in: the entrances now overlap between 6% and 24% so several elements move in the same frame, and each element leaves on its own gradual fade of 12% or more. Re-measured, 06 came back at 3.433% area with 18 of 23 frames moving — 24 frames are captured, which leaves 23 gaps to compare.
The second was the semi-transparent ghost of fading text. The big lines of 03 first rose on an opacity ramp, and the frame where the letters were half transparent got picked as the poster — measuring the pixels put the blend rate at about 0.5. Text now avoids ramps entirely: 03 settles by translateY alone, fully opaque; 08 cuts in with steps(1, end) and then moves; the context lines are cuts too. After the fix the big lines of 03 come back at a blend rate of 0.87 or higher.
The third was a double advance in 09. Hovering the card advanced the quote, and the button did the same — but a mouse click enters the card first, so a single click skipped two lines. The button now only accepts keyboard activation, where event.detail is 0; the mouse path belongs to hover and the keyboard path to the button. Nailing the preview cuts at exactly 33% and 67% was the same lesson — if one line leaves before the next arrives, you get a one-frame blank that reads as a flicker.
The last one was outside the stage, not on it. The giant glyphs of 05 hang off the sentence with position: absolute, and a bounding box grown by rotation or scale still counts toward scrollHeight even under overflow: hidden. Their offsets stay within 28px above and below, so on a 320px phone — where the stage has 174px of vertical room — the measured overflow is zero. A bounding box grown past its cell by rotation or scale repeats with axis labels in SVG chart drawing. All nine sources ship in the zip exactly as measured for this article, and the password to open it is zqpvs5yk.
Accessibility (reduced-motion)
Under prefers-reduced-motion: reduce all nine follow one rule: the self-running entrance stops, and the quote itself stays in its finished state. The static rules come first and the .is-demo loop is layered on top, so with animation off the bar of 01 stays filled, the box of 04 stays tinted, and the watermark of 08 stays risen. Every declaration in the reduce block carries !important, because the loop selectors stack classes and would otherwise win on specificity. 09 pins the first line and keeps the button path alive. The reference is MDN's prefers-reduced-motion page.
Text contrast was measured from the actual pixel values.
| Spot | Foreground / background | Measured | Standard |
|---|---|---|---|
| 01 card text | ink / cream | 17.11:1 | passes AA and AAA |
| 02·06 stage text | ink / yellow | 12.63:1 | passes AA and AAA |
| 03 big line (22px 800) | white / orange | 3.32:1 | passes AA for large text |
| 07 block text | white / ink | 18.24:1 | passes AA and AAA |
| 04·08 stage text | ink / white | 18.24:1 | passes AA and AAA |
| 03 context line (11px) | ink / orange | 5.5:1 | passes body AA (fixed 2026-09-13) |
The context line of 03 is dressing for the demo stage, but it is body-size (11px), so it was switched to ink to clear the regular AA bar of 4.5:1; the big line is 22px bold and also clears the AA large-text bar of 3:1. The meaning lives in the HTML — wrap quotes in <blockquote>, put aria-live="polite" on a line that changes as 09 does, and aria-hidden on the stacked invisible lines. The standard for the quote element itself is on MDN's blockquote page.
More ways to move letters live in CSS Text Animation 9 Ways, and the sections that display other people's praise are in Testimonial & Social Proof Sections. How this site renders and measures demos before writing about them is on the about page.
FAQ
Can a quote style css add the quotation marks with ::before?
Yes. Injecting them with content: "\201C" as in 05 keeps the HTML clean, and since screen readers treat CSS-generated characters inconsistently, a purely decorative mark is better left out of the text. If the marks carry meaning, put them in the HTML instead. Keeping a quote box's two colors apart on a dark screen was worked out first on the switching side, in dark mode toggle animations.
Is a pull quote a different tag from a blockquote?
No. The HTML tag is <blockquote> either way; "pull quote" is the name of the use — quoting someone else (01, 02, 04) versus lifting one of your own lines and enlarging it (03, 07, 08). What changes between them is the size and the placement, not the element you reach for.
How do I recolor these for my blog?
Change the three variables at the top of each style.scss — $duration, $easing, $color. $color drives the bar in 01, the fill in 04, and the ink block in 07; the React port takes the same slots as props. You never have to touch the stage colors — only the part recolors.









