05진입하면 자라는 막대
막대 다섯 칸이 바닥에 붙은 채 납작하게 기다리다가 구간이 화면에 들어온 만큼만 위로 자라고, 각 막대가 조금씩 늦게 출발해 물결처럼 올라옵니다.
쓰임성과 수치·설문 결과처럼 숫자가 주인공인 구간, 보고서형 랜딩의 지표 섹션
animation-timeline: view()scaleYtransform-origin

<main class="stage">
<div class="db is-demo">
<div class="db__scroller">
<div class="db__pad">
<p class="db__cap">재방문율</p>
<div class="db__chart">
<div class="db__col"><span class="db__bar"></span><span class="db__axis">Q1</span></div>
<div class="db__col"><span class="db__bar"></span><span class="db__axis">Q2</span></div>
<div class="db__col"><span class="db__bar"></span><span class="db__axis">Q3</span></div>
<div class="db__col"><span class="db__bar"></span><span class="db__axis">Q4</span></div>
<div class="db__col"><span class="db__bar"></span><span class="db__axis">Q5</span></div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
// 가운데가 가장 빠른 곡선 — ease-out 은 첫 프레임이 가장 빨라서
// 대표 이미지가 아직 다 납작한 차트로 뽑힌다 (실측)
$easing: cubic-bezier(.5, 0, .5, 1);
$color: $subject-mint;
@include stage($stage-ink);
.db {
--h: clamp(148px, 58vh, 176px);
width: min(320px, 92vw);
}
.db__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.db__pad {
display: grid;
gap: $sp-3;
// 차트가 처음에는 화면 **밖**에 있어야 entry 구간이 실제로 지나간다.
// 처음부터 보이면 구간이 이미 끝난 상태라 스크롤해도 아무 일이 안 일어난다 (실측)
padding: calc(var(--h) * 1.05) 0 calc(var(--h) * .6);
}
.db__cap {
margin: 0;
color: rgba(255, 247, 230, .88);
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
// 게이지 칸은 grid 로 나눈다 — flex 균등분배보다 폭이 정확하다
.db__chart {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: $sp-2;
align-items: end;
height: 96px;
}
.db__col {
display: grid;
align-content: end;
justify-items: center;
gap: $sp-1;
}
// ── 실제 동작: 구간이 화면에 들어온 만큼 막대가 자란다.
// 바닥을 축으로 눌러 두었다가 펴는 것이라 레이아웃을 다시 계산하지 않는다 ──
.db__bar {
width: 100%;
border-radius: $r-xs;
background: $color;
transform: scaleY(.04);
transform-origin: bottom; // 규칙8: 차트막대 bottom에서 자람
// ⚠ 스크롤판과 루프판은 키프레임을 나눠 쓴다. 루프용(자랐다 다시 눕는)을 그대로 스크롤에
// 물리면 구간 끝(=다 읽은 뒤)에 막대가 도로 납작해진다 — 스크롤판은 한 방향뿐이다 (실측)
animation-name: db-grow-in;
animation-duration: 1s;
animation-timing-function: $easing;
// both — backwards 만 두면 구간이 끝난 뒤(다 읽고 더 내렸을 때) 작성 상태로 돌아가 납작해진다
animation-fill-mode: both;
animation-timeline: view(block);
}
@keyframes db-grow-in {
from { transform: scaleY(.04); }
to { transform: scaleY(1); }
}
// 막대마다 구간을 조금씩 늦춰 물결처럼 올라오게 한다
.db__col:nth-child(1) .db__bar { height: 34px; animation-range: entry 8% cover 38%; }
.db__col:nth-child(2) .db__bar { height: 52px; animation-range: entry 14% cover 44%; }
.db__col:nth-child(3) .db__bar { height: 44px; animation-range: entry 20% cover 50%; }
.db__col:nth-child(4) .db__bar { height: 68px; animation-range: entry 26% cover 56%; }
.db__col:nth-child(5) .db__bar { height: 80px; animation-range: entry 32% cover 62%; }
// 자라는 구간(22%)을 줄어드는 구간(26%)보다 짧게 잡는다 — 대표 이미지는 가장 크게
// 변한 장이라, 줄어드는 쪽이 더 빠르면 다 줄어든 빈 차트가 대표로 뽑힌다.
// 마지막 막대의 시차(100ms = 5%)까지 더해도 복귀가 89% 안에서 끝난다
@keyframes db-grow {
0% { transform: scaleY(.04); }
22%, 58% { transform: scaleY(1); }
84%, 100% { transform: scaleY(.04); }
}
.db__axis {
color: rgba(255, 247, 230, .62);
font-size: 10px;
line-height: 14px;
font-weight: 500;
}
// 미리보기 루프는 실제로 스크롤하지 않으니 위 여백을 원래대로 줄여 차트를 화면 안에 둔다
.db.is-demo .db__pad { padding-top: calc(var(--h) * .22); }
// ── 격자 미리보기 루프 — 같은 곡선을 시간 축에서 시차만 주고 돌린다.
// 시차에는 animation-fill-mode: backwards 가 반드시 붙는다.
// 없으면 지연 구간에 작성 상태가 첫 프레임에 한 번 번쩍인다(252 실측) ──
.db.is-demo .db__bar {
animation-name: db-grow;
animation-duration: $duration;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-timeline: auto;
}
.db.is-demo .db__col:nth-child(2) .db__bar { animation-delay: 25ms; }
.db.is-demo .db__col:nth-child(3) .db__bar { animation-delay: 50ms; }
.db.is-demo .db__col:nth-child(4) .db__bar { animation-delay: 75ms; }
.db.is-demo .db__col:nth-child(5) .db__bar { animation-delay: 100ms; }
@media (prefers-reduced-motion: reduce) {
.db__bar { animation: none !important; transform: scaleY(1); }
}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);
}
.db {
--h: clamp(148px, 58vh, 176px);
width: min(320px, 92vw);
}
.db__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
}
.db__scroller::-webkit-scrollbar {
display: none;
}
.db__pad {
display: grid;
gap: 12px;
padding: calc(var(--h) * 1.05) 0 calc(var(--h) * 0.6);
}
.db__cap {
margin: 0;
color: rgba(255, 247, 230, 0.88);
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
.db__chart {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
align-items: end;
height: 96px;
}
.db__col {
display: grid;
align-content: end;
justify-items: center;
gap: 4px;
}
.db__bar {
width: 100%;
border-radius: 4px;
background: #4cd4a6;
transform: scaleY(0.04);
transform-origin: bottom;
animation-name: db-grow-in;
animation-duration: 1s;
animation-timing-function: cubic-bezier(0.5, 0, 0.5, 1);
animation-fill-mode: both;
animation-timeline: view(block);
}
@keyframes db-grow-in {
from {
transform: scaleY(0.04);
}
to {
transform: scaleY(1);
}
}
.db__col:nth-child(1) .db__bar {
height: 34px;
animation-range: entry 8% cover 38%;
}
.db__col:nth-child(2) .db__bar {
height: 52px;
animation-range: entry 14% cover 44%;
}
.db__col:nth-child(3) .db__bar {
height: 44px;
animation-range: entry 20% cover 50%;
}
.db__col:nth-child(4) .db__bar {
height: 68px;
animation-range: entry 26% cover 56%;
}
.db__col:nth-child(5) .db__bar {
height: 80px;
animation-range: entry 32% cover 62%;
}
@keyframes db-grow {
0% {
transform: scaleY(0.04);
}
22%, 58% {
transform: scaleY(1);
}
84%, 100% {
transform: scaleY(0.04);
}
}
.db__axis {
color: rgba(255, 247, 230, 0.62);
font-size: 10px;
line-height: 14px;
font-weight: 500;
}
.db.is-demo .db__pad {
padding-top: calc(var(--h) * 0.22);
}
.db.is-demo .db__bar {
animation-name: db-grow;
animation-duration: 2s;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-timeline: auto;
}
.db.is-demo .db__col:nth-child(2) .db__bar {
animation-delay: 25ms;
}
.db.is-demo .db__col:nth-child(3) .db__bar {
animation-delay: 50ms;
}
.db.is-demo .db__col:nth-child(4) .db__bar {
animation-delay: 75ms;
}
.db.is-demo .db__col:nth-child(5) .db__bar {
animation-delay: 100ms;
}
@media (prefers-reduced-motion: reduce) {
.db__bar {
animation: none !important;
transform: scaleY(1);
}
}// 실제 동작은 CSS 만: 막대마다 animation-timeline: view() 와 서로 다른 animation-range 가 걸려 있다.
var r = document.querySelector('.db');
var s = r.querySelector('.db__scroller');
function stopDemo(){ r.classList.remove('is-demo'); }
r.addEventListener('pointerdown', stopDemo);
s.addEventListener('scroll', stopDemo, { passive: true });import type { CSSProperties, ReactNode } from "react";
import styles from "./DataBarsGrow.module.scss";
export type DataBarsGrowProps = {
caption?: string;
labels?: string[];
preview?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function DataBarsGrow({
caption = "Return visits",
labels = ["Q1", "Q2", "Q3", "Q4", "Q5"],
preview = true,
duration = 2000,
easing = "cubic-bezier(.5, 0, .5, 1)",
color = "#4cd4a6",
}: DataBarsGrowProps): ReactNode {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = preview ? `${styles.db} ${styles.isDemo}` : styles.db;
return (
<div className={cls} style={vars}>
<div className={styles.scroller}>
<div className={styles.pad}>
<p className={styles.cap}>{caption}</p>
<div className={styles.chart}>
{labels.map((l: string): ReactNode => (
<div className={styles.col} key={l}>
<span className={styles.bar} />
<span className={styles.axis}>{l}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}05 진입하면 자라는 막대 — 측정 누적면적 11.046% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 스크롤 스토리텔링 섹션 9종 — 복붙하는 CSS 장면 구성
이 데모가 속한 글
스크롤 스토리텔링 섹션 9종 — 복붙하는 CSS 장면 구성스크롤 스토리텔링은 스크롤 위치를 시간축 삼아 한 구간 안의 장면을 차례로 바꾸는 구성입니다. 여기 아홉 개는 효과가 아니라 장면을 짜는 방식이 서로 다르고, 전부 라이브러리 없이 CSS만으로 돕니다. 이 글의 데모 전부 보기


