09오늘의 수와 시간대 막대를 세는 요약 카운터
오늘의 건수가 갱신될 때마다 aria-live 로 읽히고, 시간대별 막대는 grid-template-columns 의 같은 눈금 위에서 transform-origin 을 아래에 두고 scaleY 로 자라납니다.
쓰임대시보드 한구석의 오늘 요약 카드, 방문자 현황 위젯
aria-livescaleYtransform-origin

<main class="stage">
<section class="sc is-demo" id="sc" aria-label="오늘의 활동 요약">
<header class="sc__h">
<span class="sc__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="m96 40l33.52 88H56Zm104 88h-70.48L160 208Z" opacity=".2"/><path d="M240 128a8 8 0 0 1-8 8h-27.06l-37.78 75.58A8 8 0 0 1 160 216h-.4a8 8 0 0 1-7.08-5.14L95.35 60.76l-32.07 70.55A8 8 0 0 1 56 136H24a8 8 0 0 1 0-16h26.85l37.87-83.31a8 8 0 0 1 14.76.46l57.51 151l31.85-63.71A8 8 0 0 1 200 120h32a8 8 0 0 1 8 8"/></g></svg></span>
<h2 class="sc__t">오늘의 활동</h2>
<button class="sc__re" type="button" id="sc-re">새로 집계</button>
</header>
<p class="sc__cap" aria-live="polite"><span class="sc__n" id="sc-n" data-n="12"><i class="sc__d sc__d--a" aria-hidden="true">0</i><i class="sc__d sc__d--b" aria-hidden="true">4</i><i class="sc__d sc__d--c" aria-hidden="true">12</i></span><span class="sc__unit">건의 활동</span></p>
<div class="sc__chart" role="img" aria-label="시간대별 활동 막대 — 아홉 시부터 열네 시까지">
<div class="sc__col"><span class="sc__barw" style="--f:.30"><i class="sc__bar"></i></span><span class="sc__hr">09</span></div>
<div class="sc__col"><span class="sc__barw" style="--f:.55"><i class="sc__bar"></i></span><span class="sc__hr">10</span></div>
<div class="sc__col"><span class="sc__barw" style="--f:1"><i class="sc__bar"></i></span><span class="sc__hr">11</span></div>
<div class="sc__col"><span class="sc__barw" style="--f:.80"><i class="sc__bar"></i></span><span class="sc__hr">12</span></div>
<div class="sc__col"><span class="sc__barw" style="--f:.45"><i class="sc__bar"></i></span><span class="sc__hr">13</span></div>
<div class="sc__col"><span class="sc__barw" style="--f:.65"><i class="sc__bar sc__bar--last"></i></span><span class="sc__hr">14</span></div>
</div>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
$ink: $stage-orange;
.sc {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid rgba(255, 247, 230, .35);
border-radius: $r-card;
background: rgba(23, 20, 26, .08);
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.sc__h {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0 0 $sp-2;
}
.sc__ic { display: inline-flex; width: 14px; height: 14px; color: $stage-yellow; }
.sc__t { margin: 0; font-size: 12px; font-weight: 700; }
.sc__re {
margin-left: auto;
padding: 0 $sp-2;
border: 1px solid rgba(255, 247, 230, .5);
border-radius: $r-pill;
background: transparent;
color: $subject-ink;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.sc__re:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.sc__re:active { transform: scale(.94); }
// ── 오늘의 수 ─────────────────────────────────────
.sc__cap {
display: flex;
align-items: baseline;
gap: $sp-1;
margin: 0 0 $sp-2;
}
.sc__n {
position: relative;
display: inline-block;
min-width: 2ch;
font-size: 28px;
font-weight: 800;
line-height: 1;
text-align: right;
color: $subject-cream;
}
.sc__d { position: absolute; inset: 0; visibility: hidden; font-style: normal; }
.sc__d--c { visibility: visible; }
.sc__unit { color: $subject-ink; font-size: 11px; font-weight: 700; }
// ── 시간대별 막대 — 같은 눈금의 grid, 밑뿌리 scaleY ──
.sc__chart {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: $sp-2;
align-items: end;
}
.sc__col { display: grid; justify-items: center; gap: $sp-1; }
.sc__barw {
display: block;
width: 100%;
height: 56px;
}
.sc__bar {
display: block;
width: 100%;
height: 100%;
border-radius: $r-xs $r-xs 0 0;
background: $color;
transform-origin: bottom; // 규칙8: 막대 밑뿌리에서 성장
transform: scaleY(var(--f));
transition: transform 400ms $easing; // 실제 집계 갱신용
}
.sc__barw.is-up .sc__bar { background: $stage-yellow; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 막대 하나 = 애니메이션 하나: 밑뿌리에서 stagger 로 자라다 한 바퀴 끝에 접는다
@keyframes sc-cycle {
0% { transform: scaleY(0); }
55% { transform: scaleY(var(--f)); }
90% { transform: scaleY(var(--f)); }
100% { transform: scaleY(0); }
}
@keyframes sc-d-a { 0%, 24% { visibility: visible; } 25%, 100% { visibility: hidden; } }
@keyframes sc-d-b { 0%, 24% { visibility: hidden; } 25%, 54% { visibility: visible; } 55%, 100% { visibility: hidden; } }
@keyframes sc-d-c { 0%, 54% { visibility: hidden; } 55%, 86% { visibility: visible; } 87%, 100% { visibility: hidden; } }
@keyframes sc-re-press {
0%, 82% { transform: translateY(0); }
85% { transform: translateY(2px) scale(.95); }
89%, 100% { transform: translateY(0) scale(1); }
}
@keyframes sc-unit-pop {
0%, 54% { transform: translateY(0); }
58% { transform: translateY(-2px); }
62%, 86% { transform: translateY(0); }
87%, 100% { transform: translateY(0); }
}
.sc.is-demo {
.sc__bar {
animation-name: sc-cycle;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
// 시차 — 낮은 특이도 리셋 함정: 단축 없이 낱개 속성, delay 는 nth-child 로 더 뒤에 둔다
.sc__chart .sc__col:nth-child(1) .sc__bar { animation-delay: 0s; }
.sc__chart .sc__col:nth-child(2) .sc__bar { animation-delay: 80ms; }
.sc__chart .sc__col:nth-child(3) .sc__bar { animation-delay: 160ms; }
.sc__chart .sc__col:nth-child(4) .sc__bar { animation-delay: 240ms; }
.sc__chart .sc__col:nth-child(5) .sc__bar { animation-delay: 320ms; }
.sc__chart .sc__col:nth-child(6) .sc__bar { animation-delay: 400ms; }
.sc__d--a { animation-name: sc-d-a; }
.sc__d--b { animation-name: sc-d-b; }
.sc__d--c { animation-name: sc-d-c; }
.sc__d {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sc__re {
animation-name: sc-re-press;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.sc__unit {
animation-name: sc-unit-pop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.sc { padding: $sp-2; font-size: 11px; }
.sc__h { margin-bottom: $sp-1; }
.sc__n { font-size: 24px; }
.sc__cap { margin-bottom: $sp-1; }
.sc__chart { gap: $sp-1; }
.sc__barw { height: 44px; }
.sc__hr { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.sc__bar { transition: none; }
.sc__re:active { transform: none; }
.sc.is-demo .sc__bar,
.sc.is-demo .sc__re,
.sc.is-demo .sc__unit,
.sc.is-demo .sc__d { animation: none !important; transform: none !important; }
// 움직임만 끄고 결과 상태는 남긴다 — 막대는 다 찬 상태, 숫자는 마지막 값
.sc.is-demo .sc__bar { transform: scaleY(var(--f)) !important; }
.sc.is-demo .sc__d--a, .sc.is-demo .sc__d--b { visibility: hidden !important; }
.sc.is-demo .sc__d--c { visibility: visible !important; }
}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);
}
.sc {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgba(255, 247, 230, 0.35);
border-radius: 16px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.sc__h {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
}
.sc__ic {
display: inline-flex;
width: 14px;
height: 14px;
color: #ffd23f;
}
.sc__t {
margin: 0;
font-size: 12px;
font-weight: 700;
}
.sc__re {
margin-left: auto;
padding: 0 8px;
border: 1px solid rgba(255, 247, 230, 0.5);
border-radius: 999px;
background: transparent;
color: #17141a;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.sc__re:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.sc__re:active {
transform: scale(0.94);
}
.sc__cap {
display: flex;
align-items: baseline;
gap: 4px;
margin: 0 0 8px;
}
.sc__n {
position: relative;
display: inline-block;
min-width: 2ch;
font-size: 28px;
font-weight: 800;
line-height: 1;
text-align: right;
color: #fff7e6;
}
.sc__d {
position: absolute;
inset: 0;
visibility: hidden;
font-style: normal;
}
.sc__d--c {
visibility: visible;
}
.sc__unit {
color: #17141a;
font-size: 11px;
font-weight: 700;
}
.sc__chart {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
align-items: end;
}
.sc__col {
display: grid;
justify-items: center;
gap: 4px;
}
.sc__barw {
display: block;
width: 100%;
height: 56px;
}
.sc__bar {
display: block;
width: 100%;
height: 100%;
border-radius: 4px 4px 0 0;
background: #fff7e6;
transform-origin: bottom;
transform: scaleY(var(--f));
transition: transform 400ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sc__barw.is-up .sc__bar {
background: #ffd23f;
}
@keyframes sc-cycle {
0% {
transform: scaleY(0);
}
55% {
transform: scaleY(var(--f));
}
90% {
transform: scaleY(var(--f));
}
100% {
transform: scaleY(0);
}
}
@keyframes sc-d-a {
0%, 24% {
visibility: visible;
}
25%, 100% {
visibility: hidden;
}
}
@keyframes sc-d-b {
0%, 24% {
visibility: hidden;
}
25%, 54% {
visibility: visible;
}
55%, 100% {
visibility: hidden;
}
}
@keyframes sc-d-c {
0%, 54% {
visibility: hidden;
}
55%, 86% {
visibility: visible;
}
87%, 100% {
visibility: hidden;
}
}
@keyframes sc-re-press {
0%, 82% {
transform: translateY(0);
}
85% {
transform: translateY(2px) scale(0.95);
}
89%, 100% {
transform: translateY(0) scale(1);
}
}
@keyframes sc-unit-pop {
0%, 54% {
transform: translateY(0);
}
58% {
transform: translateY(-2px);
}
62%, 86% {
transform: translateY(0);
}
87%, 100% {
transform: translateY(0);
}
}
.sc.is-demo .sc__bar {
animation-name: sc-cycle;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.sc.is-demo .sc__chart .sc__col:nth-child(1) .sc__bar {
animation-delay: 0s;
}
.sc.is-demo .sc__chart .sc__col:nth-child(2) .sc__bar {
animation-delay: 80ms;
}
.sc.is-demo .sc__chart .sc__col:nth-child(3) .sc__bar {
animation-delay: 160ms;
}
.sc.is-demo .sc__chart .sc__col:nth-child(4) .sc__bar {
animation-delay: 240ms;
}
.sc.is-demo .sc__chart .sc__col:nth-child(5) .sc__bar {
animation-delay: 320ms;
}
.sc.is-demo .sc__chart .sc__col:nth-child(6) .sc__bar {
animation-delay: 400ms;
}
.sc.is-demo .sc__d--a {
animation-name: sc-d-a;
}
.sc.is-demo .sc__d--b {
animation-name: sc-d-b;
}
.sc.is-demo .sc__d--c {
animation-name: sc-d-c;
}
.sc.is-demo .sc__d {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sc.is-demo .sc__re {
animation-name: sc-re-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.sc.is-demo .sc__unit {
animation-name: sc-unit-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.sc {
padding: 8px;
font-size: 11px;
}
.sc__h {
margin-bottom: 4px;
}
.sc__n {
font-size: 24px;
}
.sc__cap {
margin-bottom: 4px;
}
.sc__chart {
gap: 4px;
}
.sc__barw {
height: 44px;
}
.sc__hr {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc__bar {
transition: none;
}
.sc__re:active {
transform: none;
}
.sc.is-demo .sc__bar,
.sc.is-demo .sc__re,
.sc.is-demo .sc__unit,
.sc.is-demo .sc__d {
animation: none !important;
transform: none !important;
}
.sc.is-demo .sc__bar {
transform: scaleY(var(--f)) !important;
}
.sc.is-demo .sc__d--a, .sc.is-demo .sc__d--b {
visibility: hidden !important;
}
.sc.is-demo .sc__d--c {
visibility: visible !important;
}
}// 실제 동작: 새로 집계를 누르면 숫자가 실제로 올라가고 마지막 막대가 실제로 자란다.
// 숫자가 바뀌는 순간 aria-live 로 다시 읽힌다.
var sc = document.getElementById('sc');
var scN = document.getElementById('sc-n');
var scLast = document.querySelector('.sc__bar--last');
document.getElementById('sc-re').addEventListener('click', function () {
sc.classList.remove('is-demo');
var from = Number(scN.getAttribute('data-n'));
var to = from + 1;
var t0 = null;
function tick(ts) {
if (!t0) { t0 = ts; }
var p = Math.min(1, (ts - t0) / 400);
var v = Math.round(from + (to - from) * p);
scN.textContent = String(v);
if (p < 1) { requestAnimationFrame(tick); }
}
requestAnimationFrame(tick);
scN.setAttribute('data-n', String(to));
var w = scLast.parentNode;
w.style.setProperty('--f', '.85'); // 막대 자체가 실제로 자란다
scLast.parentNode.classList.add('is-up');
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SummaryCounterBars.module.scss";
export type SummaryCounterBarsProps = {
groupLabel?: string;
chartLabel?: string;
heading?: string;
recountLabel?: string;
unitLabel?: string;
hours?: string[];
bars?: number[]; // 0~1 비율
startCount?: number;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** useRef 의 로컬 타입 — ambient 의 useRef<T> 는 { current: T | null } 를 돌려준다 */
type RafBox = { current: number | null };
function PulseIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="m96 40l33.52 88H56Zm104 88h-70.48L160 208Z" opacity=".2" />
<path d="M240 128a8 8 0 0 1-8 8h-27.06l-37.78 75.58A8 8 0 0 1 160 216h-.4a8 8 0 0 1-7.08-5.14L95.35 60.76l-32.07 70.55A8 8 0 0 1 56 136H24a8 8 0 0 1 0-16h26.85l37.87-83.31a8 8 0 0 1 14.76.46l57.51 151l31.85-63.71A8 8 0 0 1 200 120h32a8 8 0 0 1 8 8" />
</g>
</svg>
);
}
const DEFAULT_HOURS = ["09", "10", "11", "12", "13", "14"];
const DEFAULT_BARS = [0.3, 0.55, 1, 0.8, 0.45, 0.65];
export default function SummaryCounterBars({
groupLabel = "Today's activity summary",
chartLabel = "Activity bars by hour — 9am to 2pm",
heading = "Today's activity",
recountLabel = "Recount",
unitLabel = " activities so far",
hours = DEFAULT_HOURS,
bars = DEFAULT_BARS,
startCount = 12,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: SummaryCounterBarsProps) {
const [live, setLive] = useState(demo);
const [count, setCount] = useState(startCount);
const [fracs, setFracs] = useState<number[]>(bars);
const [up, setUp] = useState(false);
const rafBox: RafBox = useRef<number | null>(null);
// 언마운트 대기 중인 rAF 정리
useEffect(() => () => { if (rafBox.current !== null) cancelAnimationFrame(rafBox.current); }, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function recount() {
setLive(false);
const from = count;
const to = count + 1;
let t0: number | null = null;
const tick = (ts: number) => {
if (t0 === null) t0 = ts;
const p = Math.min(1, (ts - t0) / 400);
setCount(Math.round(from + (to - from) * p));
if (p < 1) {
rafBox.current = requestAnimationFrame(tick);
}
};
rafBox.current = requestAnimationFrame(tick);
setFracs((f) => f.map((v, i) => (i === f.length - 1 ? 0.85 : v))); // 마지막 막대가 실제로 자란다
setUp(true);
}
const cls = [styles.sc, live ? styles.isDemo : ""].filter(Boolean).join(" ");
const digits = ["0", "4", String(startCount)];
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<span className={styles.ic} aria-hidden="true"><PulseIcon /></span>
<h2 className={styles.t}>{heading}</h2>
<button className={styles.re} type="button" onClick={recount}>{recountLabel}</button>
</header>
<p className={styles.cap} aria-live="polite">
<span className={styles.n}>
{live ? (
digits.map((d, i) => (
<i key={i} className={styles[`d${"ABC"[i]}`]} aria-hidden="true">{d}</i>
))
) : (
count
)}
</span>
<span className={styles.unit}>{unitLabel}</span>
</p>
<div className={styles.chart} role="img" aria-label={chartLabel}>
{fracs.map((f, i) => (
<div key={i} className={styles.col}>
<span className={[styles.barw, i === fracs.length - 1 && up ? styles.isUp : ""].filter(Boolean).join(" ")} style={{ "--f": f }}>
<i className={styles.bar}></i>
</span>
<span className={styles.hr}>{hours[i]}</span>
</div>
))}
</div>
</section>
);
}09 오늘의 수와 시간대 막대를 세는 요약 카운터 — 측정 누적면적 7.003% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 알림 목록 ui 9가지 — 사이드바에 박히는 활동 피드와 이력 위젯
이 데모가 속한 글
알림 목록 ui 9가지 — 사이드바에 박히는 활동 피드와 이력 위젯알림 목록 ui는 떴다 사라지는 토스트가 아니라 화면 한구석에 자리를 잡고 계속 쌓이는 목록을 말합니다. 사이드바에 박히는 이 아홉 개 위젯은 읽음 표시, 고치기 전 값, 로그 수준, 오늘의 건수까지 전부 실제 상태 이 글의 데모 전부 보기


