mask/activity-feed
A scrollable feed whose top and bottom edges fade in and out with the scroll position, driven by an onScroll handler writing --mask-top-fade and --mask-bottom-fade. This is the pattern Scroll uses internally.
'use client';
import { useRef, type CSSProperties, type ReactNode, type UIEvent } from 'react';
import { Mask } from 'ui-lab-components/mask';
import { Icon } from 'ui-lab-components/icon';
// How far the reader has to scroll before an edge fade reaches full size, and
// how tall that full-size fade is relative to the container. Two knobs, same as
// the ones Scroll exposes as `fadeDistance` and `fadeSize`.
const FADE_RAMP_PX = 40;
const FADE_SIZE_PERCENT = 10;
// `<Mask.Fade />` with no props selects the scroll-fade preset, but only when
// the root already carries these two custom properties — they are what the
// preset's gradient reads. Declare them up front (top closed, bottom open,
// because the list starts at the top with more content below) and let the
// scroll handler do nothing but rewrite their values.
const INITIAL_FADE_VARS = {
'--mask-top-fade': '0%',
'--mask-bottom-fade': `${FADE_SIZE_PERCENT}%`,
} as CSSProperties;
const ACTIVITY = [
{ id: 'a1', icon: 'rocket', tone: 'text-accent-400', actor: 'Priya Raman', action: 'deployed', target: 'web-app@4.12.0 to production', time: '2m' },
{ id: 'a2', icon: 'check-circle', tone: 'text-success-500', actor: 'CI', action: 'passed 218 checks on', target: 'release/4.12', time: '9m' },
{ id: 'a3', icon: 'pr', tone: 'text-foreground-400', actor: 'Tomas Lindqvist', action: 'merged', target: '#2841 Cache invalidation on tenant switch', time: '31m' },
{ id: 'a4', icon: 'warning', tone: 'text-warning-500', actor: 'Alerting', action: 'flagged elevated p95 latency on', target: 'api-gateway (eu-west-1)', time: '1h' },
{ id: 'a5', icon: 'commit', tone: 'text-foreground-400', actor: 'Dana Whitfield', action: 'pushed 4 commits to', target: 'feat/usage-billing', time: '2h' },
{ id: 'a6', icon: 'user-plus', tone: 'text-info-500', actor: 'Marcus Oyelaran', action: 'invited', target: 'j.okafor@northwind.io as Reviewer', time: '3h' },
{ id: 'a7', icon: 'check-circle', tone: 'text-success-500', actor: 'Migrations', action: 'applied', target: '0142_add_seat_limits on all shards', time: '5h' },
{ id: 'a8', icon: 'pr', tone: 'text-foreground-400', actor: 'Priya Raman', action: 'opened', target: '#2846 Retry webhook deliveries with jitter', time: '7h' },
{ id: 'a9', icon: 'rocket', tone: 'text-accent-400', actor: 'Tomas Lindqvist', action: 'rolled back', target: 'worker@4.11.3 in staging', time: '11h' },
{ id: 'a10', icon: 'commit', tone: 'text-foreground-400', actor: 'Dana Whitfield', action: 'tagged', target: 'v4.11.2', time: '19h' },
];
function ActivityFeed(): ReactNode {
const maskRef = useRef<HTMLDivElement>(null);
// Top fade grows as content disappears above; bottom fade shrinks to nothing
// as the reader lands on the last row — so a fade only ever means "there is
// more this way".
function handleScroll(event: UIEvent<HTMLDivElement>) {
const viewport = event.currentTarget;
const mask = maskRef.current;
if (!mask) return;
const maxScroll = viewport.scrollHeight - viewport.clientHeight;
const above = maxScroll > 0 ? Math.min(1, viewport.scrollTop / FADE_RAMP_PX) : 0;
const below = maxScroll > 0 ? Math.min(1, (maxScroll - viewport.scrollTop) / FADE_RAMP_PX) : 0;
mask.style.setProperty('--mask-top-fade', `${above * FADE_SIZE_PERCENT}%`);
mask.style.setProperty('--mask-bottom-fade', `${below * FADE_SIZE_PERCENT}%`);
}
return (
<Mask ref={maskRef} className="h-64 overflow-hidden" style={INITIAL_FADE_VARS}>
<Mask.Fade />
<div className="h-full overflow-y-auto px-4 py-3" onScroll={handleScroll}>
<ul className="flex flex-col gap-3">
{ACTIVITY.map(({ id, icon, tone, actor, action, target, time }) => (
<li className="flex items-start gap-3" key={id}>
<Icon id={icon} size={14} className={`mt-0.5 shrink-0 ${tone}`} />
<p className="min-w-0 flex-1 text-sm leading-relaxed text-foreground-300">
<span className="font-medium text-foreground-100">{actor}</span> {action}{' '}
<span className="text-foreground-200">{target}</span>
</p>
<span className="shrink-0 text-xs text-foreground-400">{time}</span>
</li>
))}
</ul>
</div>
</Mask>
);
}
export default function Example() {
return (
<div className="overflow-hidden rounded-md border border-background-700 bg-background-900">
<div className="flex items-center justify-between border-b border-background-700 px-4 py-2.5">
<h3 className="text-sm font-semibold text-foreground-100">Workspace activity</h3>
<span className="text-xs text-foreground-400">Last 24 hours</span>
</div>
<ActivityFeed />
</div>
);
}