08Summary row stuck to the floor that recounts
Roll the table up and down and the summary row stays pinned to the floor, and while more rows still run on beneath it a shallow shadow rests on that row to say this is not the end. Pick a row and the total adds up only what you picked.
Best forLong sales or settlement tables you have to scroll
position: stickybox-shadowtranslateY

<main class="stage">
<div class="sm is-demo" id="sm">
<div class="sm__head" role="row">
<span role="columnheader">Period</span><span role="columnheader">Amount</span>
</div>
<div class="sm__view" id="sm-view" role="grid" aria-label="Settlement list" tabindex="0">
<div class="sm__track" id="sm-track" role="rowgroup">
<button class="sm__row" type="button" role="row" data-v="42"><span role="gridcell">Mar wk 1</span><span role="gridcell">42</span></button>
<button class="sm__row" type="button" role="row" data-v="38"><span role="gridcell">Mar wk 2</span><span role="gridcell">38</span></button>
<button class="sm__row" type="button" role="row" data-v="51"><span role="gridcell">Mar wk 3</span><span role="gridcell">51</span></button>
<button class="sm__row" type="button" role="row" data-v="27"><span role="gridcell">Mar wk 4</span><span role="gridcell">27</span></button>
<button class="sm__row" type="button" role="row" data-v="60"><span role="gridcell">Apr wk 1</span><span role="gridcell">60</span></button>
</div>
<div class="sm__sum" id="sm-sum" role="row">
<span class="sm__sic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M192 48v160H64l64-80l-64-80Z" opacity=".2"/><path d="M184 72V56H80.65l53.6 67a8 8 0 0 1 0 10l-53.6 67H184v-16a8 8 0 0 1 16 0v24a8 8 0 0 1-8 8H64a8 8 0 0 1-6.25-13l60-75l-60-75A8 8 0 0 1 64 40h128a8 8 0 0 1 8 8v24a8 8 0 0 1-16 0"/></g></svg></span>
<span class="sm__label" role="gridcell">Total</span>
<span class="sm__v" role="gridcell"><b class="sm__all" id="sm-all">218</b><b class="sm__sel">38</b></span>
</div>
</div>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
$row-h: 26px;
$ink: $subject-ink;
$line: color.mix($ink, $stage-paper, 12%);
.sm {
box-sizing: border-box;
width: min(276px, 92vw);
border: 2px solid $ink;
border-radius: $r-card;
background: $stage-paper;
overflow: hidden;
color: $ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sm__head {
display: grid;
grid-template-columns: 1fr 72px;
height: 24px;
border-bottom: 2px solid $ink;
background: color.mix($ink, $stage-paper, 8%);
}
.sm__head > * {
padding: 0 $sp-2;
font-size: 11px;
line-height: 24px;
font-weight: 700;
}
.sm__head > :last-child { text-align: right; }
// ── 스크롤 상자 — 여기에는 transform 을 걸지 않는다 ────
.sm__view {
position: relative;
height: 78px;
overflow-y: auto;
overscroll-behavior: contain;
}
.sm__view:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.sm__track { display: block; }
.sm__row {
display: grid;
grid-template-columns: 1fr 72px;
width: 100%;
height: $row-h;
padding: 0;
border: 0;
box-shadow: inset 0 -1px 0 $line;
background: $stage-paper;
color: $ink;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.sm__row > * {
padding: 0 $sp-2;
font-size: 12px;
line-height: $row-h;
font-weight: 500;
}
.sm__row > :last-child { text-align: right; font-weight: 700; }
.sm__row.is-pick { background: rgba(47, 109, 246, .14); }
.sm__row:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
// ── 바닥에 붙는 합계 줄 ───────────────────────────────
.sm__sum {
position: sticky;
bottom: 0;
z-index: 2;
display: grid;
grid-template-columns: 16px 1fr 72px;
align-items: center;
height: 24px;
border-top: 2px solid $ink;
background: $stage-yellow;
box-shadow: none;
}
.sm.is-more .sm__sum { box-shadow: 0 -6px 10px rgba(23, 20, 26, .22); }
.sm__sic { display: inline-flex; width: 12px; height: 12px; margin-left: $sp-1; }
.sm__label {
padding: 0 $sp-1;
font-size: 11px;
line-height: 24px;
font-weight: 700;
}
.sm__v {
position: relative;
padding: 0 $sp-2;
font-size: 12px;
line-height: 24px;
text-align: right;
}
.sm__all, .sm__sel { font-weight: 800; }
.sm__sel { position: absolute; right: 8px; top: 0; color: $color; opacity: 0; }
.sm__sum.is-part .sm__label { color: $color; }
// ── 자동 루프 (2s) ───────────────────────────────────
// 스크롤 상자(.sm__view) 자체가 아니라 안쪽 줄묶음(.sm__track)만 옮긴다 — 상자에 transform 을
// 걸면 100% 가 상자 자기 폭 기준이라 내용이 통째로 날아간다(051 실측과 같은 계열).
// 10~44% 내려가고 44% 에 끝에 닿아 그림자를 끈다. 56% 에 한 줄을 골라 합계가 부분합으로 컷 교체.
.sm.is-demo .sm__track {
animation-name: sm-scroll;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.sm.is-demo .sm__sum {
animation-name: sm-shade;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__row:nth-child(2) {
animation-name: sm-pick;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__all {
animation-name: sm-off;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__sel {
animation-name: sm-on;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sm-scroll {
0% { transform: translateY(0); }
10% { transform: translateY(0); }
44% { transform: translateY(-52px); }
70% { transform: translateY(-52px); }
86% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@keyframes sm-shade {
0% { box-shadow: 0 -6px 10px rgba(23, 20, 26, .22); }
44% { box-shadow: none; }
78% { box-shadow: 0 -6px 10px rgba(23, 20, 26, .22); }
100% { box-shadow: 0 -6px 10px rgba(23, 20, 26, .22); }
}
@keyframes sm-pick {
0% { background: $stage-paper; }
56% { background: rgba(47, 109, 246, .14); }
76% { background: $stage-paper; }
100% { background: $stage-paper; }
}
@keyframes sm-off {
0% { opacity: 1; }
56% { opacity: 0; }
76% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes sm-on {
0% { opacity: 0; }
56% { opacity: 1; }
76% { opacity: 0; }
100% { opacity: 0; }
}
// 320×200 — 금액 열과 셀 여백만 줄인다. 스크롤 상자 높이는 그대로 둔다
@media (max-width: 340px) {
.sm__head, .sm__row { grid-template-columns: 1fr 60px; }
.sm__sum { grid-template-columns: 16px 1fr 60px; }
.sm__head > *, .sm__row > * { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.sm.is-demo .sm__track,
.sm.is-demo .sm__sum,
.sm.is-demo .sm__row:nth-child(2),
.sm.is-demo .sm__all,
.sm.is-demo .sm__sel { animation: none !important; }
.sm.is-demo .sm__sum { box-shadow: 0 -6px 10px rgba(23, 20, 26, .22); }
}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);
}
.sm {
box-sizing: border-box;
width: min(276px, 92vw);
border: 2px solid #17141a;
border-radius: 16px;
background: #ffffff;
overflow: hidden;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sm__head {
display: grid;
grid-template-columns: 1fr 72px;
height: 24px;
border-bottom: 2px solid #17141a;
background: rgb(92.7215686275%, 92.6274509804%, 92.8156862745%);
}
.sm__head > * {
padding: 0 8px;
font-size: 11px;
line-height: 24px;
font-weight: 700;
}
.sm__head > :last-child {
text-align: right;
}
.sm__view {
position: relative;
height: 78px;
overflow-y: auto;
overscroll-behavior: contain;
}
.sm__view:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.sm__track {
display: block;
}
.sm__row {
display: grid;
grid-template-columns: 1fr 72px;
width: 100%;
height: 26px;
padding: 0;
border: 0;
box-shadow: inset 0 -1px 0 rgb(89.0823529412%, 88.9411764706%, 89.2235294118%);
background: #ffffff;
color: #17141a;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.sm__row > * {
padding: 0 8px;
font-size: 12px;
line-height: 26px;
font-weight: 500;
}
.sm__row > :last-child {
text-align: right;
font-weight: 700;
}
.sm__row.is-pick {
background: rgba(47, 109, 246, 0.14);
}
.sm__row:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.sm__sum {
position: sticky;
bottom: 0;
z-index: 2;
display: grid;
grid-template-columns: 16px 1fr 72px;
align-items: center;
height: 24px;
border-top: 2px solid #17141a;
background: #ffd23f;
box-shadow: none;
}
.sm.is-more .sm__sum {
box-shadow: 0 -6px 10px rgba(23, 20, 26, 0.22);
}
.sm__sic {
display: inline-flex;
width: 12px;
height: 12px;
margin-left: 4px;
}
.sm__label {
padding: 0 4px;
font-size: 11px;
line-height: 24px;
font-weight: 700;
}
.sm__v {
position: relative;
padding: 0 8px;
font-size: 12px;
line-height: 24px;
text-align: right;
}
.sm__all, .sm__sel {
font-weight: 800;
}
.sm__sel {
position: absolute;
right: 8px;
top: 0;
color: #2f6df6;
opacity: 0;
}
.sm__sum.is-part .sm__label {
color: #2f6df6;
}
.sm.is-demo .sm__track {
animation-name: sm-scroll;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__sum {
animation-name: sm-shade;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__row:nth-child(2) {
animation-name: sm-pick;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__all {
animation-name: sm-off;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__sel {
animation-name: sm-on;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sm-scroll {
0% {
transform: translateY(0);
}
10% {
transform: translateY(0);
}
44% {
transform: translateY(-52px);
}
70% {
transform: translateY(-52px);
}
86% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes sm-shade {
0% {
box-shadow: 0 -6px 10px rgba(23, 20, 26, 0.22);
}
44% {
box-shadow: none;
}
78% {
box-shadow: 0 -6px 10px rgba(23, 20, 26, 0.22);
}
100% {
box-shadow: 0 -6px 10px rgba(23, 20, 26, 0.22);
}
}
@keyframes sm-pick {
0% {
background: #ffffff;
}
56% {
background: rgba(47, 109, 246, 0.14);
}
76% {
background: #ffffff;
}
100% {
background: #ffffff;
}
}
@keyframes sm-off {
0% {
opacity: 1;
}
56% {
opacity: 0;
}
76% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes sm-on {
0% {
opacity: 0;
}
56% {
opacity: 1;
}
76% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.sm__head, .sm__row {
grid-template-columns: 1fr 60px;
}
.sm__sum {
grid-template-columns: 16px 1fr 60px;
}
.sm__head > *, .sm__row > * {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-demo .sm__track,
.sm.is-demo .sm__sum,
.sm.is-demo .sm__row:nth-child(2),
.sm.is-demo .sm__all,
.sm.is-demo .sm__sel {
animation: none !important;
}
.sm.is-demo .sm__sum {
box-shadow: 0 -6px 10px rgba(23, 20, 26, 0.22);
}
}// 실제 동작: Total 줄은 스크롤 상자 안에서 bottom:0 에 붙어 있고, 끝까지 내려가면 그림자를 끈다.
var sm = document.getElementById('sm');
var smView = document.getElementById('sm-view');
var smAll = document.getElementById('sm-all');
function smRows() { return [].slice.call(document.querySelectorAll('.sm__row')); }
function smSum() {
var picked = smRows().filter(function (r) { return r.classList.contains('is-pick'); });
var list = picked.length ? picked : smRows();
smAll.textContent = String(list.reduce(function (n, r) { return n + (+r.dataset.v); }, 0));
document.getElementById('sm-sum').classList.toggle('is-part', picked.length > 0);
}
function smShade() {
var left = smView.scrollHeight - smView.scrollTop - smView.clientHeight;
sm.classList.toggle('is-more', left > 2);
}
smView.addEventListener('scroll', function () { sm.classList.remove('is-demo'); smShade(); });
smRows().forEach(function (row) {
row.addEventListener('click', function () {
sm.classList.remove('is-demo');
row.classList.toggle('is-pick');
row.setAttribute('aria-selected', row.classList.contains('is-pick') ? 'true' : 'false');
smSum();
});
});
smSum(); smShade();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StickySummaryRow.module.scss";
export type SummaryRow = { name: string; value: number };
export type StickySummaryRowProps = {
gridLabel?: string;
periodLabel?: string;
amountLabel?: string;
totalLabel?: string;
rows?: SummaryRow[];
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ROWS: SummaryRow[] = [
{ name: "Mar wk 1", value: 42 },
{ name: "Mar wk 2", value: 38 },
{ name: "Mar wk 3", value: 51 },
{ name: "Mar wk 4", value: 27 },
{ name: "Apr wk 1", value: 60 },
];
export default function StickySummaryRow({
gridLabel = "Settlement list",
periodLabel = "Period",
amountLabel = "Amount",
totalLabel = "Total",
rows = ROWS,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: StickySummaryRowProps) {
const [picked, setPicked] = useState<number[]>([]);
const [more, setMore] = useState(true);
const [live, setLive] = useState(demo);
const viewRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 고른 줄이 있으면 그것만, 없으면 전부 더한다
const list = picked.length ? rows.filter((_r, i) => picked.indexOf(i) >= 0) : rows;
const sum = list.reduce((n, r) => n + r.value, 0);
// 루프에서 두 번째 줄만 골랐을 때 컷으로 갈아 끼우는 부분합
const sample = rows.length > 1 ? rows[1].value : sum;
// 끝까지 내려가면 그림자를 끈다
const shade = () => {
const el = viewRef.current;
if (!el) return;
setMore(el.scrollHeight - el.scrollTop - el.clientHeight > 2);
};
useEffect(() => { shade(); }, []);
const toggle = (i: number) => {
setLive(false);
setPicked((prev) => (prev.indexOf(i) >= 0 ? prev.filter((n) => n !== i) : prev.concat([i])));
};
const cls = [styles.root, live ? styles.isDemo : "", more ? styles.isMore : ""].join(" ");
return (
<div className={cls} style={vars}>
<div className={styles.head} role="row">
<span role="columnheader">{periodLabel}</span>
<span role="columnheader">{amountLabel}</span>
</div>
<div
className={styles.view}
role="grid"
aria-label={gridLabel}
tabIndex={0}
ref={viewRef}
onScroll={() => { setLive(false); shade(); }}
>
<div className={styles.track} role="rowgroup">
{rows.map((r, i) => (
<button
key={r.name}
className={[styles.row, picked.indexOf(i) >= 0 ? styles.isPick : ""].join(" ")}
type="button"
role="row"
aria-selected={picked.indexOf(i) >= 0 ? "true" : "false"}
onClick={() => toggle(i)}
>
<span role="gridcell">{r.name}</span>
<span role="gridcell">{r.value}</span>
</button>
))}
</div>
<div className={[styles.sum, picked.length ? styles.isPart : ""].join(" ")} role="row">
<span className={styles.sic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M192 48v160H64l64-80l-64-80Z" opacity=".2" />
<path d="M184 72V56H80.65l53.6 67a8 8 0 0 1 0 10l-53.6 67H184v-16a8 8 0 0 1 16 0v24a8 8 0 0 1-8 8H64a8 8 0 0 1-6.25-13l60-75l-60-75A8 8 0 0 1 64 40h128a8 8 0 0 1 8 8v24a8 8 0 0 1-16 0" />
</g>
</svg>
</span>
<span className={styles.label} role="gridcell">{totalLabel}</span>
<span className={styles.v} role="gridcell">
<b className={styles.all}>{sum}</b>
<b className={styles.sel}>{sample}</b>
</span>
</div>
</div>
</div>
);
}08 Summary row stuck to the floor that recounts — Measured area 9.225% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · 9 Data Table UI Patterns — Drag, Resize, Select
The post this demo lives in
9 Data Table UI Patterns — Drag, Resize, SelectA data table ui is a grid of rows and columns you can reorder, narrow, and pull values out of by hand. All nine sort a r See all demos in this post
If you like this effect - similar demos
Lists & tables02 Odometer counter — splitting the counter value into digits so number markers rollCSS List Style: 9 Marker Design Patterns
Lists & tables04 Empty-List Message That Appears When the Last Row Is GoneInfinite Scroll Implementation — 9 List UI Basics
Lists & tables05 Gold Gloss That Sweeps Only Across the No. 1 RowLeaderboard UI Design: 9 Rank-Change List Patterns