08바닥에 붙어 다시 계산되는 합계 줄
표를 위아래로 굴려도 맨 아래 합계 줄은 바닥에 붙은 채 남고, 그 줄 밑으로 아직 이어지는 행이 있는 동안에는 얕은 그림자가 떠 여기가 끝이 아니라는 것을 알려 줍니다. 줄을 골라 두면 합계는 고른 줄만 다시 더합니다.
쓰임줄이 길어 스크롤해야 하는 매출·정산 표
position: stickybox-shadowtranslateY

<main class="stage">
<div class="sm is-demo" id="sm">
<div class="sm__head" role="row">
<span role="columnheader">주차</span><span role="columnheader">금액</span>
</div>
<div class="sm__view" id="sm-view" role="grid" aria-label="정산 목록" 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">3월 1주</span><span role="gridcell">42</span></button>
<button class="sm__row" type="button" role="row" data-v="38"><span role="gridcell">3월 2주</span><span role="gridcell">38</span></button>
<button class="sm__row" type="button" role="row" data-v="51"><span role="gridcell">3월 3주</span><span role="gridcell">51</span></button>
<button class="sm__row" type="button" role="row" data-v="27"><span role="gridcell">3월 4주</span><span role="gridcell">27</span></button>
<button class="sm__row" type="button" role="row" data-v="60"><span role="gridcell">4월 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">합계</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);
}
}// 실제 동작: 합계 줄은 스크롤 상자 안에서 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 바닥에 붙어 다시 계산되는 합계 줄 — 측정 누적면적 9.225% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 데이터 테이블 UI 9종 — 열을 끌어 넓히고 셀을 골라 복사하는 표
이 데모가 속한 글
데이터 테이블 UI 9종 — 열을 끌어 넓히고 셀을 골라 복사하는 표데이터 테이블 ui는 줄과 칸으로 늘어놓은 값을 사람이 직접 만져서 다시 배열하고 좁히고 꺼내 가는 화면입니다. 아홉 개 전부 시늉이 아니라 실제로 값을 다시 정렬하고 문자열을 비교하며, 합계는 볼 때마다 그 자리에 이 글의 데모 전부 보기


