02Change history diff that puts values back
The old value is struck through with del and the new one underlined with ins, and each row's undo button snaps the shown value back to the previous one in a single steps(1, end) cut.
Best forper-setting edit history on a settings screen, value changes on a quote
delinssteps(1, end)

<main class="stage">
<section class="dh is-demo" id="dh" aria-label="Quote change history">
<header class="dh__h">
<span class="dh__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg></span>
<h2 class="dh__t">Change history</h2>
<span class="dh__n" id="dh-n" data-t="{n} edited" aria-live="polite">3 edited</span>
</header>
<ul class="dh__list" id="dh-list">
<li class="dh__row" data-row="1">
<span class="dh__f">Due date</span>
<p class="dh__diff">
<span class="dh__a"><del>Oct 15</del> → <ins>Oct 8</ins></span>
<span class="dh__b"><b>Oct 15</b><i class="dh__tag">Reverted</i></span>
</p>
<button class="dh__undo" type="button" aria-pressed="false" data-f="Due date">
<span class="dh__u" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg></span>
Undo
</button>
</li>
<li class="dh__row" data-row="2">
<span class="dh__f">Owner</span>
<p class="dh__diff">
<span class="dh__a"><del>Lee Jun</del> → <ins>Park Seo-a</ins></span>
<span class="dh__b"><b>Lee Jun</b><i class="dh__tag">Reverted</i></span>
</p>
<button class="dh__undo" type="button" aria-pressed="false" data-f="Owner">
<span class="dh__u" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg></span>
Undo
</button>
</li>
<li class="dh__row" data-row="3">
<span class="dh__f">Qty</span>
<p class="dh__diff">
<span class="dh__a"><del>120</del> → <ins>150</ins></span>
<span class="dh__b"><b>120</b><i class="dh__tag">Reverted</i></span>
</p>
<button class="dh__undo" type="button" aria-pressed="false" data-f="Qty">
<span class="dh__u" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg></span>
Undo
</button>
</li>
</ul>
<p class="dh__live" aria-live="polite" id="dh-live">Undoing an item reports here</p>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
$line: color.mix($subject-ink, $stage-paper, 14%);
$soft: color.mix($subject-blue, $stage-paper, 7%);
.dh {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-paper;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.dh__h {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0 0 $sp-2;
}
.dh__ic { display: inline-flex; width: 14px; height: 14px; color: $color; }
.dh__t { margin: 0; font-size: 12px; font-weight: 700; }
.dh__n {
margin-left: auto;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $soft;
color: $subject-blue-deep;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
}
.dh__list {
display: grid;
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
// ── 이력 한 줄 ────────────────────────────────────
.dh__row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border: 1px solid $line;
border-radius: $r-sm;
background: $stage-paper;
}
.dh__f { color: rgba(23, 20, 26, .72); font-weight: 700; }
// 두 상태를 겹쳐 두고 steps 로 자른다 — 반투명 유령 프레임 금지
.dh__diff { position: relative; margin: 0; min-height: 18px; }
.dh__a, .dh__b { position: absolute; inset: 0; display: inline-flex; align-items: center; gap: $sp-1; }
.dh__b { visibility: hidden; }
.dh__row.is-back .dh__a { visibility: hidden; }
.dh__row.is-back .dh__b { visibility: visible; }
.dh__diff del {
color: $error;
font-size: 11px;
text-decoration: line-through;
text-decoration-thickness: 1.5px;
}
.dh__diff ins {
color: $color;
font-weight: 700;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.dh__b b { color: $subject-ink; font-weight: 700; }
.dh__tag {
padding: 0 4px;
border-radius: $r-xs;
background: $subject-mint;
color: $subject-ink;
font-size: 10px;
font-style: normal;
font-weight: 700;
}
.dh__undo {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 $sp-2;
border: 1px solid $line;
border-radius: $r-pill;
background: $stage-paper;
color: $subject-ink;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.dh__undo:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.dh__u { display: inline-flex; width: 11px; height: 11px; }
.dh__row.is-back .dh__undo { background: $soft; color: $color; }
.dh__row.is-back .dh__u { transform: rotate(-360deg); transition: transform $dur-base $easing; }
.dh__live {
margin: $sp-2 0 0;
color: rgba(23, 20, 26, .72);
font-size: 10px;
}
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 되돌림은 steps 컷으로 한 프레임에 자르고, 프레임 수는 아이콘 회전과 칩 팝이 채운다
@keyframes dh-a-1 { 0%, 24% { visibility: visible; } 25%, 87% { visibility: hidden; } 88%, 100% { visibility: visible; } }
@keyframes dh-b-1 { 0%, 24% { visibility: hidden; } 25%, 87% { visibility: visible; } 88%, 100% { visibility: hidden; } }
@keyframes dh-a-2 { 0%, 54% { visibility: visible; } 55%, 87% { visibility: hidden; } 88%, 100% { visibility: visible; } }
@keyframes dh-b-2 { 0%, 54% { visibility: hidden; } 55%, 87% { visibility: visible; } 88%, 100% { visibility: hidden; } }
@keyframes dh-spin-1 {
0%, 24% { transform: rotate(0deg); }
30%, 87% { transform: rotate(-360deg); }
88%, 100% { transform: rotate(0deg); }
}
@keyframes dh-spin-2 {
0%, 54% { transform: rotate(0deg); }
60%, 87% { transform: rotate(-360deg); }
88%, 100% { transform: rotate(0deg); }
}
@keyframes dh-pop-1 {
0%, 24% { transform: scale(1); }
27% { transform: scale(1.14); }
31%, 87% { transform: scale(1); }
88%, 100% { transform: scale(1); }
}
@keyframes dh-pop-2 {
0%, 54% { transform: scale(1); }
57% { transform: scale(1.14); }
61%, 100% { transform: scale(1); }
}
@keyframes dh-n-tick {
0%, 24% { transform: translateX(0); }
25%, 54% { transform: translateX(-2px); }
55%, 87% { transform: translateX(-4px); }
88%, 100% { transform: translateX(0); }
}
.dh.is-demo {
.dh__row[data-row="1"] .dh__a { animation-name: dh-a-1; }
.dh__row[data-row="1"] .dh__b { animation-name: dh-b-1; }
.dh__row[data-row="2"] .dh__a { animation-name: dh-a-2; }
.dh__row[data-row="2"] .dh__b { animation-name: dh-b-2; }
.dh__a, .dh__b {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dh__row[data-row="1"] .dh__u { animation-name: dh-spin-1; }
.dh__row[data-row="2"] .dh__u { animation-name: dh-spin-2; }
.dh__u {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.dh__row[data-row="1"] .dh__undo { animation-name: dh-pop-1; }
.dh__row[data-row="2"] .dh__undo { animation-name: dh-pop-2; }
.dh__undo {
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
.dh__n {
animation-name: dh-n-tick;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.dh { padding: $sp-2; font-size: 11px; }
.dh__h { margin-bottom: $sp-1; }
.dh__row { padding: 0 $sp-2; }
.dh__f { font-size: 10px; }
.dh__undo { font-size: 10px; }
.dh__live { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.dh__row.is-back .dh__u { transform: none; transition: none; }
.dh.is-demo .dh__a,
.dh.is-demo .dh__b,
.dh.is-demo .dh__u,
.dh.is-demo .dh__undo,
.dh.is-demo .dh__n { animation: none !important; transform: none !important; }
// 움직임만 끄고 판정 결과는 남긴다 — 1·2번 줄은 되돌린 상태
.dh.is-demo .dh__row[data-row="1"] .dh__a,
.dh.is-demo .dh__row[data-row="2"] .dh__a { visibility: hidden !important; }
.dh.is-demo .dh__row[data-row="1"] .dh__b,
.dh.is-demo .dh__row[data-row="2"] .dh__b { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dh {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 16px;
background: #ffffff;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.dh__h {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
}
.dh__ic {
display: inline-flex;
width: 14px;
height: 14px;
color: #2f6df6;
}
.dh__t {
margin: 0;
font-size: 12px;
font-weight: 700;
}
.dh__n {
margin-left: auto;
padding: 0 8px;
border-radius: 999px;
background: rgb(94.2901960784%, 95.9921568627%, 99.7529411765%);
color: #0a4fe8;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
}
.dh__list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.dh__row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 8px;
background: #ffffff;
}
.dh__f {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
}
.dh__diff {
position: relative;
margin: 0;
min-height: 18px;
}
.dh__a, .dh__b {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
gap: 4px;
}
.dh__b {
visibility: hidden;
}
.dh__row.is-back .dh__a {
visibility: hidden;
}
.dh__row.is-back .dh__b {
visibility: visible;
}
.dh__diff del {
color: #c2361a;
font-size: 11px;
text-decoration: line-through;
text-decoration-thickness: 1.5px;
}
.dh__diff ins {
color: #2f6df6;
font-weight: 700;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.dh__b b {
color: #17141a;
font-weight: 700;
}
.dh__tag {
padding: 0 4px;
border-radius: 4px;
background: #4cd4a6;
color: #17141a;
font-size: 10px;
font-style: normal;
font-weight: 700;
}
.dh__undo {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.7;
cursor: pointer;
}
.dh__undo:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.dh__u {
display: inline-flex;
width: 11px;
height: 11px;
}
.dh__row.is-back .dh__undo {
background: rgb(94.2901960784%, 95.9921568627%, 99.7529411765%);
color: #2f6df6;
}
.dh__row.is-back .dh__u {
transform: rotate(-360deg);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dh__live {
margin: 8px 0 0;
color: rgba(23, 20, 26, 0.72);
font-size: 10px;
}
@keyframes dh-a-1 {
0%, 24% {
visibility: visible;
}
25%, 87% {
visibility: hidden;
}
88%, 100% {
visibility: visible;
}
}
@keyframes dh-b-1 {
0%, 24% {
visibility: hidden;
}
25%, 87% {
visibility: visible;
}
88%, 100% {
visibility: hidden;
}
}
@keyframes dh-a-2 {
0%, 54% {
visibility: visible;
}
55%, 87% {
visibility: hidden;
}
88%, 100% {
visibility: visible;
}
}
@keyframes dh-b-2 {
0%, 54% {
visibility: hidden;
}
55%, 87% {
visibility: visible;
}
88%, 100% {
visibility: hidden;
}
}
@keyframes dh-spin-1 {
0%, 24% {
transform: rotate(0deg);
}
30%, 87% {
transform: rotate(-360deg);
}
88%, 100% {
transform: rotate(0deg);
}
}
@keyframes dh-spin-2 {
0%, 54% {
transform: rotate(0deg);
}
60%, 87% {
transform: rotate(-360deg);
}
88%, 100% {
transform: rotate(0deg);
}
}
@keyframes dh-pop-1 {
0%, 24% {
transform: scale(1);
}
27% {
transform: scale(1.14);
}
31%, 87% {
transform: scale(1);
}
88%, 100% {
transform: scale(1);
}
}
@keyframes dh-pop-2 {
0%, 54% {
transform: scale(1);
}
57% {
transform: scale(1.14);
}
61%, 100% {
transform: scale(1);
}
}
@keyframes dh-n-tick {
0%, 24% {
transform: translateX(0);
}
25%, 54% {
transform: translateX(-2px);
}
55%, 87% {
transform: translateX(-4px);
}
88%, 100% {
transform: translateX(0);
}
}
.dh.is-demo .dh__row[data-row="1"] .dh__a {
animation-name: dh-a-1;
}
.dh.is-demo .dh__row[data-row="1"] .dh__b {
animation-name: dh-b-1;
}
.dh.is-demo .dh__row[data-row="2"] .dh__a {
animation-name: dh-a-2;
}
.dh.is-demo .dh__row[data-row="2"] .dh__b {
animation-name: dh-b-2;
}
.dh.is-demo .dh__a, .dh.is-demo .dh__b {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dh.is-demo .dh__row[data-row="1"] .dh__u {
animation-name: dh-spin-1;
}
.dh.is-demo .dh__row[data-row="2"] .dh__u {
animation-name: dh-spin-2;
}
.dh.is-demo .dh__u {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.dh.is-demo .dh__row[data-row="1"] .dh__undo {
animation-name: dh-pop-1;
}
.dh.is-demo .dh__row[data-row="2"] .dh__undo {
animation-name: dh-pop-2;
}
.dh.is-demo .dh__undo {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.dh.is-demo .dh__n {
animation-name: dh-n-tick;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.dh {
padding: 8px;
font-size: 11px;
}
.dh__h {
margin-bottom: 4px;
}
.dh__row {
padding: 0 8px;
}
.dh__f {
font-size: 10px;
}
.dh__undo {
font-size: 10px;
}
.dh__live {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.dh__row.is-back .dh__u {
transform: none;
transition: none;
}
.dh.is-demo .dh__a,
.dh.is-demo .dh__b,
.dh.is-demo .dh__u,
.dh.is-demo .dh__undo,
.dh.is-demo .dh__n {
animation: none !important;
transform: none !important;
}
.dh.is-demo .dh__row[data-row="1"] .dh__a,
.dh.is-demo .dh__row[data-row="2"] .dh__a {
visibility: hidden !important;
}
.dh.is-demo .dh__row[data-row="1"] .dh__b,
.dh.is-demo .dh__row[data-row="2"] .dh__b {
visibility: visible !important;
}
}// 실제 동작: 되돌리기를 누른 항목만 이전 값으로 돌아간다 (화면 표시와 aria 상태 둘 다).
var dh = document.getElementById('dh');
var dhN = document.getElementById('dh-n');
var dhLive = document.getElementById('dh-live');
var dhLeft = 3;
function dhCount() {
dhN.textContent = dhN.getAttribute('data-t').replace('{n}', dhLeft);
}
document.querySelectorAll('.dh__undo').forEach(function (b) {
b.addEventListener('click', function () {
dh.classList.remove('is-demo');
var row = b.closest('.dh__row');
var back = row.classList.toggle('is-back');
b.setAttribute('aria-pressed', back ? 'true' : 'false');
dhLeft += back ? -1 : 1;
dhCount();
dhLive.textContent = back
? b.getAttribute('data-f') + ' was restored to the previous value'
: b.getAttribute('data-f') + ' was edited again';
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ChangeHistoryDiff.module.scss";
export type ChangeHistoryDiffProps = {
heading?: string;
groupLabel?: string;
undoLabel?: string;
revertedTag?: string;
liveHint?: string;
restoredMsg?: string;
editedAgainMsg?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Field = { key: string; f: string; oldV: string; newV: string };
const FIELDS: Field[] = [
{ key: "due", f: "Due date", oldV: "Oct 15", newV: "Oct 8" },
{ key: "owner", f: "Owner", oldV: "Lee Jun", newV: "Park Seo-a" },
{ key: "qty", f: "Qty", oldV: "120", newV: "150" },
];
function UndoIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" />
<path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128" />
</g>
</svg>
);
}
export default function ChangeHistoryDiff({
heading = "Change history",
groupLabel = "Quote change history",
undoLabel = "Undo",
revertedTag = "Reverted",
liveHint = "Undoing an item reports here",
restoredMsg = " was restored to the previous value",
editedAgainMsg = " was edited again",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ChangeHistoryDiffProps) {
const [live, setLive] = useState(demo);
const [back, setBack] = useState<Record<string, boolean>>({});
const [msg, setMsg] = useState(liveHint);
const left = FIELDS.filter((x) => !back[x.key]).length;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function undo(k: string, f: string) {
setLive(false);
const nextVal = !back[k];
setBack({ ...back, [k]: nextVal });
setMsg(nextVal ? f + restoredMsg : f + editedAgainMsg);
}
const cls = [styles.dh, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<span className={styles.ic} aria-hidden="true"><UndoIcon /></span>
<h2 className={styles.t}>{heading}</h2>
<span className={styles.n} aria-live="polite">
{left} edited
</span>
</header>
<ul className={styles.list}>
{FIELDS.map((x, i) => (
<li key={x.key} className={[styles.row, i + 1 <= 2 ? styles[`r${i + 1}`] : "", back[x.key] ? styles.isBack : ""].filter(Boolean).join(" ")}>
<span className={styles.f}>{x.f}</span>
<p className={styles.diff}>
<span className={styles.a}>
<del>{x.oldV}</del> → <ins>{x.newV}</ins>
</span>
<span className={styles.b}>
<b>{x.oldV}</b>
<i className={styles.tag}>{revertedTag}</i>
</span>
</p>
<button className={styles.undoBtn} type="button" aria-pressed={!!back[x.key]} onClick={() => undo(x.key, x.f)}>
<span className={styles.u} aria-hidden="true"><UndoIcon /></span>
{undoLabel}
</button>
</li>
))}
</ul>
<p className={styles.live} aria-live="polite">{msg}</p>
</section>
);
}02 Change history diff that puts values back — Measured area 2.813% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · 9 Activity Feed UI Widgets — Sidebar-Ready Lists
The post this demo lives in
9 Activity Feed UI Widgets — Sidebar-Ready ListsAn activity feed ui is a list that keeps stacking in a screen corner, not a toast that flashes and fades. These nine Sid See all demos in this post

