09表の中の小さな線がカードに広がる
表の行に載った小さな折れ線に触れるとその行がカードに広がり、線が太くなって軸と最高点の印が現れます。
使いどころ行ごとに推移を付けた商品・チャネルの実績テーブル
vector-effectgrid-template-rows:focus-visible

<main class="stage">
<figure class="sp is-demo" id="sp">
<figcaption class="sp__head">
<span class="sp__title">チャネル別売上推移</span>
<span class="sp__val" aria-live="polite" id="sp-live">行を選ぶとカードに広がります</span>
</figcaption>
<div class="sp__rows" id="sp-rows" data-open="0">
<button class="sp__row" type="button" data-i="0" aria-label="検索の12週間売上、現在180万円">
<span class="sp__name">検索</span>
<svg class="sp__spark" viewBox="0 0 100 56" preserveAspectRatio="xMidYMid meet" role="img" aria-label="検索の推移折れ線、最高180万円、最低120万円">
<g class="sp__axis" aria-hidden="true">
<line x1="2" y1="29" x2="98" y2="29"/><line x1="6" y1="50" x2="6" y2="53"/><line x1="50" y1="50" x2="50" y2="53"/><line x1="94" y1="50" x2="94" y2="53"/>
</g>
<line class="sp__base" x1="2" y1="50" x2="98" y2="50" aria-hidden="true"/>
<polyline class="sp__line" points="6,46 14,38 22,42 30,33 38,27 46,30 54,22 62,17 70,20 78,13 86,16 94,8"/>
<g class="sp__peak" aria-hidden="true"><circle cx="94" cy="8" r="3"/><text x="88" y="11" text-anchor="end">最高180</text></g>
</svg>
<span class="sp__num">180<small>万円</small><i class="sp__delta">+50.0%</i></span>
</button>
<button class="sp__row" type="button" data-i="1" aria-label="直接の12週間売上、現在102万円">
<span class="sp__name">直接</span>
<svg class="sp__spark" viewBox="0 0 100 56" preserveAspectRatio="xMidYMid meet" role="img" aria-label="直接の推移折れ線、最高134万円、最低98万円">
<g class="sp__axis" aria-hidden="true">
<line x1="2" y1="29" x2="98" y2="29"/><line x1="6" y1="50" x2="6" y2="53"/><line x1="50" y1="50" x2="50" y2="53"/><line x1="94" y1="50" x2="94" y2="53"/>
</g>
<line class="sp__base" x1="2" y1="50" x2="98" y2="50" aria-hidden="true"/>
<polyline class="sp__line" points="6,46 14,40 22,31 30,35 38,22 46,8 54,14 62,25 70,19 78,27 86,34 94,42"/>
<g class="sp__peak" aria-hidden="true"><circle cx="46" cy="8" r="3"/><text x="40" y="11" text-anchor="end">最高134</text></g>
</svg>
<span class="sp__num">102<small>万円</small><i class="sp__delta">+4.1%</i></span>
</button>
<button class="sp__row" type="button" data-i="2" aria-label="紹介の12週間売上、現在118万円">
<span class="sp__name">紹介</span>
<svg class="sp__spark" viewBox="0 0 100 56" preserveAspectRatio="xMidYMid meet" role="img" aria-label="紹介の推移折れ線、最高118万円、最低76万円">
<g class="sp__axis" aria-hidden="true">
<line x1="2" y1="29" x2="98" y2="29"/><line x1="6" y1="50" x2="6" y2="53"/><line x1="50" y1="50" x2="50" y2="53"/><line x1="94" y1="50" x2="94" y2="53"/>
</g>
<line class="sp__base" x1="2" y1="50" x2="98" y2="50" aria-hidden="true"/>
<polyline class="sp__line" points="6,46 14,39 22,42 30,32 38,35 46,29 54,22 62,26 70,19 78,13 86,17 94,8"/>
<g class="sp__peak" aria-hidden="true"><circle cx="94" cy="8" r="3"/><text x="88" y="11" text-anchor="end">最高118</text></g>
</svg>
<span class="sp__num">118<small>万円</small><i class="sp__delta">+55.3%</i></span>
</button>
</div>
<p class="sp__hint">行に載せるかタブで当てるとカードに広がります</p>
<div class="sr"><table>
<caption>チャネル別12週間の売上</caption>
<thead><tr><th>チャネル</th><th>現在</th><th>最高</th><th>最低</th></tr></thead>
<tbody>
<tr><th>検索</th><td>180</td><td>180</td><td>120</td></tr>
<tr><th>直接</th><td>102</td><td>134</td><td>98</td></tr>
<tr><th>紹介</th><td>118</td><td>118</td><td>76</td></tr>
</tbody>
</table></div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 접힘 36px · 펼침 68px — 펼친 만큼 나머지 두 행이 20px 로 줄어 총 높이는 그대로다.
// 그래서 카드가 무대를 넘치지 않는다(320×200 포함)
$fold: 36px;
$open: 68px;
$squash: 20px;
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.sp {
box-sizing: border-box;
width: min(392px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
margin: 0;
padding: $sp-3;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sp__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: 0 $sp-1;
}
.sp__title { font-size: 13px; line-height: 18px; font-weight: 800; letter-spacing: .02em; }
.sp__val { font-size: 12px; line-height: 18px; font-weight: 700; color: $color; font-variant-numeric: tabular-nums; text-align: right; }
// ── 행 목록: 높이 분배를 grid-template-rows 하나가 독점한다 ──
// 어떤 행이 열리든 세 트랙 값만 바뀌고, 트랙 보간이 세 행을 한 덩이처럼 밀어낸다
.sp__rows {
display: grid;
grid-template-rows: $fold $fold $fold;
gap: $sp-1;
transition-property: grid-template-rows;
transition-duration: $duration;
transition-timing-function: $easing;
}
.sp__rows[data-open="1"],
.sp__rows:has(.sp__row[data-i="0"]:hover),
.sp__rows:has(.sp__row[data-i="0"]:focus-visible) { grid-template-rows: $open $squash $squash; }
.sp__rows[data-open="2"],
.sp__rows:has(.sp__row[data-i="1"]:hover),
.sp__rows:has(.sp__row[data-i="1"]:focus-visible) { grid-template-rows: $squash $open $squash; }
.sp__rows[data-open="3"],
.sp__rows:has(.sp__row[data-i="2"]:hover),
.sp__rows:has(.sp__row[data-i="2"]:focus-visible) { grid-template-rows: $squash $squash $open; }
// ── 행 버튼 ── <td> 가 아니라 버튼 자체가 grid 칸이라 익명 table-cell 함정이 없다
.sp__row {
--open: 0; // 안쪽 요소가 이 값으로 축·눈금·증감칩을 낸다
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(0, 1fr) 96px max-content;
align-items: center;
column-gap: $sp-2;
margin: 0;
padding: 0 $sp-2;
border: 0;
border-radius: $r-sm;
background: $stage-paper;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0);
overflow: hidden; // 접힌 행에서 증감침이 밖으로 새어나가지 않게
cursor: pointer;
font-family: inherit;
text-align: left;
transition-property: background-color, box-shadow;
transition-duration: $duration;
transition-timing-function: $easing;
}
.sp__row:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
@mixin open-row($i) {
.sp__rows[data-open="#{$i + 1}"] .sp__row[data-i="#{$i}"],
.sp__row[data-i="#{$i}"]:is(:hover, :focus-visible) {
--open: 1;
background: $subject-cream;
box-shadow: $shadow-press;
}
}
@include open-row(0);
@include open-row(1);
@include open-row(2);
.sp__name { font-size: 13px; line-height: 16px; font-weight: 800; white-space: nowrap; }
// ── 미니 꺾은선: vector-effect 로 선굵기가 확대 배율과 무관하게 움직인다 ──
.sp__spark { display: block; width: 100%; height: 100%; }
.sp__line {
fill: none;
stroke: $color;
stroke-width: calc(2px + var(--open) * 1.5px);
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
transition-property: stroke-width;
transition-duration: $duration;
transition-timing-function: $easing;
}
.sp__base, .sp__axis line {
fill: none;
stroke: rgba(23, 20, 26, .35);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.sp__axis, .sp__base { opacity: var(--open); }
.sp__peak {
opacity: var(--open);
transform-box: fill-box;
transform-origin: center;
transition-property: opacity, transform;
transition-duration: $duration;
transition-timing-function: $ease-pop;
transform: scale(.85);
}
.sp__row:is(:hover, :focus-visible) .sp__peak,
.sp__rows[data-open="1"] .sp__row[data-i="0"] .sp__peak,
.sp__rows[data-open="2"] .sp__row[data-i="1"] .sp__peak,
.sp__rows[data-open="3"] .sp__row[data-i="2"] .sp__peak { transform: scale(1); }
.sp__peak circle { fill: $error; }
.sp__peak text { fill: $error; font-size: 10px; font-weight: 800; }
// ── 값 · 증감칩 ── 칩은 접힌 행에서 overflow:hidden 에 가려진다
.sp__num {
position: relative;
display: block;
font-size: 15px;
line-height: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
text-align: right;
}
.sp__num small { font-size: 10px; line-height: 18px; font-weight: 700; margin-left: $sp-1; }
.sp__delta {
position: absolute;
top: 18px;
right: 0;
margin: 0;
padding: 0 $sp-1;
border-radius: $r-pill;
background: rgba(47, 109, 246, .12);
color: $color;
font-size: 10px;
line-height: 14px;
font-style: normal;
font-weight: 800;
opacity: var(--open);
transition-property: opacity;
transition-duration: $duration;
transition-timing-function: $easing;
}
.sp__hint { margin: 0; padding: 0 $sp-1; font-size: 11px; line-height: 16px; color: rgba(23, 20, 26, .62); }
// ══ 자동 루프(2초): 1행 확대 → 접기 → 2행 확대 → 접기. 복귀는 92% 안 ══
// grid-template-rows 보간 자체가 주인공이라 컷이 필요 없다 — 트랙이 늘어나는 동안
// 매 프레임이 중간 상태다. 눈금·최고점은 등장만 steps 컷(반투명 유령 방지)
.sp.is-demo .sp__rows {
animation-name: sp-rows-t;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sp-rows-t {
0%, 6% { grid-template-rows: $fold $fold $fold; }
16%, 40% { grid-template-rows: $open $squash $squash; }
50%, 56% { grid-template-rows: $fold $fold $fold; }
66%, 86% { grid-template-rows: $squash $open $squash; }
92%, 100% { grid-template-rows: $fold $fold $fold; }
}
.sp.is-demo .sp__row[data-i="0"] {
animation-name: sp-row1;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sp-row1 {
0%, 6% { background-color: $stage-paper; box-shadow: 0 6px 12px rgba(23, 20, 26, 0); }
16%, 40% { background-color: $subject-cream; box-shadow: $shadow-press; }
50%, 100% { background-color: $stage-paper; box-shadow: 0 6px 12px rgba(23, 20, 26, 0); }
}
.sp.is-demo .sp__row[data-i="1"] {
animation-name: sp-row2;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sp-row2 {
0%, 56% { background-color: $stage-paper; box-shadow: 0 6px 12px rgba(23, 20, 26, 0); }
66%, 86% { background-color: $subject-cream; box-shadow: $shadow-press; }
92%, 100% { background-color: $stage-paper; box-shadow: 0 6px 12px rgba(23, 20, 26, 0); }
}
// 선굵기 — 트랙이 늘어나는 같은 구간에 맞춰 2px → 3.5px
.sp.is-demo .sp__row[data-i="0"] .sp__line {
animation-name: sp-sw1;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sp-sw1 {
0%, 6% { stroke-width: 2px; }
16%, 40% { stroke-width: 3.5px; }
50%, 100% { stroke-width: 2px; }
}
.sp.is-demo .sp__row[data-i="1"] .sp__line {
animation-name: sp-sw2;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sp-sw2 {
0%, 56% { stroke-width: 2px; }
66%, 86% { stroke-width: 3.5px; }
92%, 100% { stroke-width: 2px; }
}
// 눈금·최고점 등장은 컷(유령 프레임 금지), 최고점 도트 팝은 transform
.sp.is-demo .sp__row[data-i="0"] .sp__axis,
.sp.is-demo .sp__row[data-i="0"] .sp__base {
animation-name: sp-ax1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sp-ax1 {
0% { opacity: 0; }
14% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 0; }
}
.sp.is-demo .sp__row[data-i="1"] .sp__axis,
.sp.is-demo .sp__row[data-i="1"] .sp__base {
animation-name: sp-ax2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sp-ax2 {
0% { opacity: 0; }
64% { opacity: 1; }
92% { opacity: 0; }
100% { opacity: 0; }
}
.sp.is-demo .sp__row[data-i="0"] .sp__peak {
animation-name: sp-pk1o, sp-pk1p;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $ease-pop;
animation-iteration-count: infinite, infinite;
}
@keyframes sp-pk1o {
0% { opacity: 0; }
14% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes sp-pk1p {
0%, 14% { transform: scale(.55); }
22% { transform: scale(1.1); }
30%, 100% { transform: scale(1); }
}
.sp.is-demo .sp__row[data-i="1"] .sp__peak {
animation-name: sp-pk2o, sp-pk2p;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $ease-pop;
animation-iteration-count: infinite, infinite;
}
@keyframes sp-pk2o {
0% { opacity: 0; }
64% { opacity: 1; }
92% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes sp-pk2p {
0%, 64% { transform: scale(.55); }
72% { transform: scale(1.1); }
80%, 100% { transform: scale(1); }
}
// 증감칩도 같은 창에 컷
.sp.is-demo .sp__row[data-i="0"] .sp__delta {
animation-name: sp-dl1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sp-dl1 {
0% { opacity: 0; }
14% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 0; }
}
.sp.is-demo .sp__row[data-i="1"] .sp__delta {
animation-name: sp-dl2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sp-dl2 {
0% { opacity: 0; }
64% { opacity: 1; }
92% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.sp { padding: $sp-2; gap: $sp-1; }
.sp__hint { display: none; }
.sp__title { font-size: 12px; line-height: 16px; }
.sp__val { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__rows,
.sp.is-demo .sp__row,
.sp.is-demo .sp__line,
.sp.is-demo .sp__axis,
.sp.is-demo .sp__base,
.sp.is-demo .sp__peak,
.sp.is-demo .sp__delta { animation: none !important; }
.sp__rows,
.sp__row,
.sp__line,
.sp__peak,
.sp__delta { transition: none !important; }
}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);
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.sp {
box-sizing: border-box;
width: min(392px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 12px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sp__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 4px;
}
.sp__title {
font-size: 13px;
line-height: 18px;
font-weight: 800;
letter-spacing: 0.02em;
}
.sp__val {
font-size: 12px;
line-height: 18px;
font-weight: 700;
color: #2f6df6;
font-variant-numeric: tabular-nums;
text-align: right;
}
.sp__rows {
display: grid;
grid-template-rows: 36px 36px 36px;
gap: 4px;
transition-property: grid-template-rows;
transition-duration: 300ms;
transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__rows[data-open="1"],
.sp__rows:has(.sp__row[data-i="0"]:hover),
.sp__rows:has(.sp__row[data-i="0"]:focus-visible) {
grid-template-rows: 68px 20px 20px;
}
.sp__rows[data-open="2"],
.sp__rows:has(.sp__row[data-i="1"]:hover),
.sp__rows:has(.sp__row[data-i="1"]:focus-visible) {
grid-template-rows: 20px 68px 20px;
}
.sp__rows[data-open="3"],
.sp__rows:has(.sp__row[data-i="2"]:hover),
.sp__rows:has(.sp__row[data-i="2"]:focus-visible) {
grid-template-rows: 20px 20px 68px;
}
.sp__row {
--open: 0;
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(0, 1fr) 96px max-content;
align-items: center;
column-gap: 8px;
margin: 0;
padding: 0 8px;
border: 0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0);
overflow: hidden;
cursor: pointer;
font-family: inherit;
text-align: left;
transition-property: background-color, box-shadow;
transition-duration: 300ms;
transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__row:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.sp__rows[data-open="1"] .sp__row[data-i="0"],
.sp__row[data-i="0"]:is(:hover, :focus-visible) {
--open: 1;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.sp__rows[data-open="2"] .sp__row[data-i="1"],
.sp__row[data-i="1"]:is(:hover, :focus-visible) {
--open: 1;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.sp__rows[data-open="3"] .sp__row[data-i="2"],
.sp__row[data-i="2"]:is(:hover, :focus-visible) {
--open: 1;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.sp__name {
font-size: 13px;
line-height: 16px;
font-weight: 800;
white-space: nowrap;
}
.sp__spark {
display: block;
width: 100%;
height: 100%;
}
.sp__line {
fill: none;
stroke: #2f6df6;
stroke-width: calc(2px + var(--open) * 1.5px);
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
transition-property: stroke-width;
transition-duration: 300ms;
transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__base, .sp__axis line {
fill: none;
stroke: rgba(23, 20, 26, 0.35);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.sp__axis, .sp__base {
opacity: var(--open);
}
.sp__peak {
opacity: var(--open);
transform-box: fill-box;
transform-origin: center;
transition-property: opacity, transform;
transition-duration: 300ms;
transition-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
transform: scale(0.85);
}
.sp__row:is(:hover, :focus-visible) .sp__peak,
.sp__rows[data-open="1"] .sp__row[data-i="0"] .sp__peak,
.sp__rows[data-open="2"] .sp__row[data-i="1"] .sp__peak,
.sp__rows[data-open="3"] .sp__row[data-i="2"] .sp__peak {
transform: scale(1);
}
.sp__peak circle {
fill: #c2361a;
}
.sp__peak text {
fill: #c2361a;
font-size: 10px;
font-weight: 800;
}
.sp__num {
position: relative;
display: block;
font-size: 15px;
line-height: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
text-align: right;
}
.sp__num small {
font-size: 10px;
line-height: 18px;
font-weight: 700;
margin-left: 4px;
}
.sp__delta {
position: absolute;
top: 18px;
right: 0;
margin: 0;
padding: 0 4px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.12);
color: #2f6df6;
font-size: 10px;
line-height: 14px;
font-style: normal;
font-weight: 800;
opacity: var(--open);
transition-property: opacity;
transition-duration: 300ms;
transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__hint {
margin: 0;
padding: 0 4px;
font-size: 11px;
line-height: 16px;
color: rgba(23, 20, 26, 0.62);
}
.sp.is-demo .sp__rows {
animation-name: sp-rows-t;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes sp-rows-t {
0%, 6% {
grid-template-rows: 36px 36px 36px;
}
16%, 40% {
grid-template-rows: 68px 20px 20px;
}
50%, 56% {
grid-template-rows: 36px 36px 36px;
}
66%, 86% {
grid-template-rows: 20px 68px 20px;
}
92%, 100% {
grid-template-rows: 36px 36px 36px;
}
}
.sp.is-demo .sp__row[data-i="0"] {
animation-name: sp-row1;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes sp-row1 {
0%, 6% {
background-color: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0);
}
16%, 40% {
background-color: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
50%, 100% {
background-color: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0);
}
}
.sp.is-demo .sp__row[data-i="1"] {
animation-name: sp-row2;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes sp-row2 {
0%, 56% {
background-color: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0);
}
66%, 86% {
background-color: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
92%, 100% {
background-color: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0);
}
}
.sp.is-demo .sp__row[data-i="0"] .sp__line {
animation-name: sp-sw1;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes sp-sw1 {
0%, 6% {
stroke-width: 2px;
}
16%, 40% {
stroke-width: 3.5px;
}
50%, 100% {
stroke-width: 2px;
}
}
.sp.is-demo .sp__row[data-i="1"] .sp__line {
animation-name: sp-sw2;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes sp-sw2 {
0%, 56% {
stroke-width: 2px;
}
66%, 86% {
stroke-width: 3.5px;
}
92%, 100% {
stroke-width: 2px;
}
}
.sp.is-demo .sp__row[data-i="0"] .sp__axis,
.sp.is-demo .sp__row[data-i="0"] .sp__base {
animation-name: sp-ax1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sp-ax1 {
0% {
opacity: 0;
}
14% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.sp.is-demo .sp__row[data-i="1"] .sp__axis,
.sp.is-demo .sp__row[data-i="1"] .sp__base {
animation-name: sp-ax2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sp-ax2 {
0% {
opacity: 0;
}
64% {
opacity: 1;
}
92% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.sp.is-demo .sp__row[data-i="0"] .sp__peak {
animation-name: sp-pk1o, sp-pk1p;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes sp-pk1o {
0% {
opacity: 0;
}
14% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes sp-pk1p {
0%, 14% {
transform: scale(0.55);
}
22% {
transform: scale(1.1);
}
30%, 100% {
transform: scale(1);
}
}
.sp.is-demo .sp__row[data-i="1"] .sp__peak {
animation-name: sp-pk2o, sp-pk2p;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes sp-pk2o {
0% {
opacity: 0;
}
64% {
opacity: 1;
}
92% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes sp-pk2p {
0%, 64% {
transform: scale(0.55);
}
72% {
transform: scale(1.1);
}
80%, 100% {
transform: scale(1);
}
}
.sp.is-demo .sp__row[data-i="0"] .sp__delta {
animation-name: sp-dl1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sp-dl1 {
0% {
opacity: 0;
}
14% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.sp.is-demo .sp__row[data-i="1"] .sp__delta {
animation-name: sp-dl2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sp-dl2 {
0% {
opacity: 0;
}
64% {
opacity: 1;
}
92% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.sp {
padding: 8px;
gap: 4px;
}
.sp__hint {
display: none;
}
.sp__title {
font-size: 12px;
line-height: 16px;
}
.sp__val {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__rows,
.sp.is-demo .sp__row,
.sp.is-demo .sp__line,
.sp.is-demo .sp__axis,
.sp.is-demo .sp__base,
.sp.is-demo .sp__peak,
.sp.is-demo .sp__delta {
animation: none !important;
}
.sp__rows,
.sp__row,
.sp__line,
.sp__peak,
.sp__delta {
transition: none !important;
}
}// 실제 동작: 행 버튼에 닿으면(hover) 그 행만 카드로 커지고 나머지 행은 밀려난다.
// 클릭·탭은 data-open 으로 고정하고, ↑↓ 키로도 행 사이를 옮긴다(:focus-visible 확대).
// 어느 행이 열렸는지는 목록의 data-open 에만 담기고 CSS 가 grid-template-rows 를 움직인다.
var root = document.getElementById('sp');
var list = document.getElementById('sp-rows');
var rows = [].slice.call(list.querySelectorAll('.sp__row'));
var live = document.getElementById('sp-live');
var cur = 0;
rows.forEach(function (r, i) {
r.addEventListener('pointerenter', function () {
root.classList.remove('is-demo');
live.textContent = r.getAttribute('aria-label');
});
r.addEventListener('click', function () {
root.classList.remove('is-demo');
var now = list.getAttribute('data-open') === String(i + 1) ? '0' : String(i + 1);
list.setAttribute('data-open', now);
live.textContent = r.getAttribute('aria-label');
});
r.addEventListener('focus', function () {
root.classList.remove('is-demo');
cur = i;
live.textContent = r.getAttribute('aria-label');
});
r.addEventListener('blur', function () { list.setAttribute('data-open', '0'); });
});
list.addEventListener('keydown', function (e) {
var d = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
root.classList.remove('is-demo');
cur = Math.min(Math.max(cur + d, 0), rows.length - 1);
rows[cur].focus({ preventScroll: true });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SparklineRowExpand.module.scss";
// 편 264 · 09 — 표 안의 작은 선이 카드로 커진다 (vanilla 와 같은 동작)
// 열린 행은 data-open 하나로 나가고 CSS 가 grid-template-rows 세 트랙을 움직인다.
export type SparklineRowExpandProps = {
title?: string;
liveInitial?: string;
hint?: string;
caption?: string;
duration?: number;
easing?: string;
color?: string;
};
type KeyEvt = { key: string; preventDefault(): void };
type Btn = { focus(o: { preventScroll: boolean }): void };
type Row = {
name: string;
points: string;
peakX: number;
peakLabel: string;
peakAnchor: "end";
value: string;
unit: string;
delta: string;
label: string;
sparkLabel: string;
max: string;
min: string;
};
const ROWS: Row[] = [
{
name: "검색", points: "6,46 14,38 22,42 30,33 38,27 46,30 54,22 62,17 70,20 78,13 86,16 94,8",
peakX: 88, peakLabel: "최고 180", peakAnchor: "end",
value: "180", unit: "만원", delta: "+50.0%",
label: "검색 12주 매출, 현재 180만원",
sparkLabel: "검색 추이 꺾은선, 최고 180만원, 최저 120만원",
max: "180", min: "120",
},
{
name: "직접", points: "6,46 14,40 22,31 30,35 38,22 46,8 54,14 62,25 70,19 78,27 86,34 94,42",
peakX: 40, peakLabel: "최고 134", peakAnchor: "end",
value: "102", unit: "만원", delta: "+4.1%",
label: "직접 12주 매출, 현재 102만원",
sparkLabel: "직접 추이 꺾은선, 최고 134만원, 최저 98만원",
max: "134", min: "98",
},
{
name: "추천", points: "6,46 14,39 22,42 30,32 38,35 46,29 54,22 62,26 70,19 78,13 86,17 94,8",
peakX: 88, peakLabel: "최고 118", peakAnchor: "end",
value: "118", unit: "만원", delta: "+55.3%",
label: "추천 12주 매출, 현재 118만원",
sparkLabel: "추천 추이 꺾은선, 최고 118만원, 최저 76만원",
max: "118", min: "76",
},
];
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SparklineRowExpand({
title = "채널별 매출 추이",
liveInitial = "행을 선택하면 카드로 확대됩니다",
hint = "행에 올리거나 탭으로 닿으면 카드로 커집니다",
caption = "채널별 12주 매출",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SparklineRowExpandProps) {
const [demo, setDemo] = useState(true);
const [open, setOpen] = useState(0); // 0 = 전부 접힘, 1..3 = 그 행 고정
const [cur, setCur] = useState(0);
const [live, setLive] = useState("");
const btns: { current: Btn[] } = { current: [] };
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const liveText = live || liveInitial;
function say(i: number) { setLive(ROWS[i].label); }
function onKey(e: KeyEvt) {
const d = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (!d) return;
e.preventDefault();
setDemo(false);
const n = Math.min(Math.max(cur + d, 0), ROWS.length - 1);
setCur(n);
const t = btns.current[n];
if (t) t.focus({ preventScroll: true });
}
return (
<figure className={`${styles.sp} ${demo ? styles.isDemo : ""}`} style={vars} onKeyDown={onKey}>
<figcaption className={styles.head}>
<span className={styles.title}>{title}</span>
<span className={styles.val} aria-live="polite">{liveText}</span>
</figcaption>
<div className={styles.rows} data-open={String(open)}>
{ROWS.map((r, i) => (
<button
key={i}
ref={(el: Btn | null) => { if (el) btns.current[i] = el; }}
className={styles.row}
type="button"
data-i={String(i)}
aria-label={r.label}
onMouseEnter={() => { setDemo(false); say(i); }}
onClick={() => { setDemo(false); setOpen(open === i + 1 ? 0 : i + 1); say(i); }}
onFocus={() => { setDemo(false); setCur(i); say(i); }}
onBlur={() => setOpen(0)}
>
<span className={styles.name}>{r.name}</span>
<svg className={styles.spark} viewBox="0 0 100 56" preserveAspectRatio="xMidYMid meet" role="img" aria-label={r.sparkLabel}>
<g className={styles.axis} aria-hidden="true">
<line x1="2" y1="29" x2="98" y2="29"/><line x1="6" y1="50" x2="6" y2="53"/><line x1="50" y1="50" x2="50" y2="53"/><line x1="94" y1="50" x2="94" y2="53"/>
</g>
<line className={styles.base} x1="2" y1="50" x2="98" y2="50" aria-hidden="true"/>
<polyline className={styles.line} points={r.points}/>
<g className={styles.peak} aria-hidden="true">
<circle cx={r.peakX === 88 ? "94" : "46"} cy="8" r="3"/>
<text x={String(r.peakX)} y="11" text-anchor={r.peakAnchor}>{r.peakLabel}</text>
</g>
</svg>
<span className={styles.num}>{r.value}<small>{r.unit}</small><i className={styles.delta}>{r.delta}</i></span>
</button>
))}
</div>
<p className={styles.hint}>{hint}</p>
<div className={styles.sr}><table>
<caption>{caption}</caption>
<thead><tr><th>채널</th><th>현재</th><th>최고</th><th>최저</th></tr></thead>
<tbody>{ROWS.map((r, i) => (<tr key={i}><th>{r.name}</th><td>{r.value}</td><td>{r.max}</td><td>{r.min}</td></tr>))}</tbody>
</table></div>
</figure>
);
}09 表の中の小さな線がカードに広がる — 計測 累積面積 26.139% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · ダッシュボード グラフ デザイン 操作9選 — 触ると数字が変わる
このデモが属する記事
ダッシュボード グラフ デザイン 操作9選 — 触ると数字が変わるダッシュボード グラフ デザインで本当に働くのは描かれた線ではなく、その上で手がすることです。9 つはすべてチャートライブラリなしで CSS の棒とインライン SVG の上に素の JS だけを載せ、触ると数字が変わるものだけを選びました。 この記事のデモをすべて見る


