Mask

Container with fading edge effects on content.

import { Mask } from 'ui-lab-components/mask';
Fade Length1.5
import { Mask } from 'ui-lab-components/mask';
 
const tags = ['api', 'billing', 'auth', 'webhooks', 'exports', 'sso'];
 
// Each Mask.Fade composites with the others, so listing several edges
// masks all of them in one pass.
// intensity is a pixel length: 40px per step, capped at 200px.
// Keep the card's border and background on a wrapper: a mask applies to
// everything the element paints, so chrome inside it fades away too.
export default function Example() {
  return (
    <div className="overflow-hidden rounded-md border border-background-700 bg-background-900">
      <Mask className="h-64 overflow-hidden">
        <Mask.Fade direction="top" intensity={1.5} />
        <Mask.Fade direction="bottom" intensity={1.5} />
        <div className="flex h-full flex-col gap-3 px-5 py-4">
          <h3 className="text-sm font-semibold text-foreground-100">Incident 4128 — postmortem</h3>
          <p className="text-sm leading-relaxed text-foreground-300">
            A permissions cache kept serving the previous tenant's scopes for up to
            nine seconds after a workspace switch. The blast radius was limited to
            read paths, but the same cache fronts write authorization, so we are
            treating it as a near miss rather than a degradation.
          </p>
          <div className="flex flex-wrap gap-2">
            {tags.map((tag) => (
              <span
                className="rounded-sm border border-background-700 bg-background-800 px-2 py-1 text-xs text-foreground-400"
                key={tag}
              >
                {tag}
              </span>
            ))}
          </div>
          <p className="text-sm leading-relaxed text-foreground-300">
            The fix pins cache keys to the resolved tenant id and invalidates on
            switch instead of on TTL expiry.
          </p>
        </div>
      </Mask>
    </div>
  );
}
 

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.

Loading interactive preview
'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>
  );
}
 

mask/integration-row

A horizontally overflowing logo row with left and right fades composited on one Mask, so tiles dissolve into the container edges instead of being cut off mid-tile.

Loading interactive preview
import type { CSSProperties } from 'react';
import { Mask } from 'ui-lab-components/mask';
import { Icon } from 'ui-lab-components/icon';
 
// Two fades on the same Mask composite instead of replacing each other, so one
// pass gives the row a soft edge on both sides. `intensity` is the fade length:
// 1.5 → 60px, roughly one logo tile, which is enough to read as "keep scrolling"
// without swallowing the tile underneath it.
const FADE_INTENSITY = 1.5;
 
// The row is the scroll container, so its scrollbar would sit inside the mask
// and get faded along with the logos. Hiding it leaves the fade as the only
// overflow affordance.
const ROW_STYLE = { scrollbarWidth: 'none' } as CSSProperties;
 
const INTEGRATIONS = [
  { id: 'github', label: 'GitHub', icon: 'github' },
  { id: 'gitlab', label: 'GitLab', icon: 'gitlab' },
  { id: 'aws', label: 'AWS', icon: 'aws' },
  { id: 'docker', label: 'Docker', icon: 'docker' },
  { id: 'slack', label: 'Slack', icon: 'slack' },
  { id: 'stripe', label: 'Stripe', icon: 'stripe' },
  { id: 'figma', label: 'Figma', icon: 'figma' },
  { id: 'react', label: 'React', icon: 'react' },
  { id: 'vue', label: 'Vue', icon: 'vue' },
  { id: 'angular', label: 'Angular', icon: 'angular' },
  { id: 'node', label: 'Node.js', icon: 'node' },
  { id: 'python', label: 'Python', icon: 'python' },
];
 
export default function Example() {
  return (
    <div className="rounded-md border border-background-700 bg-background-900 py-5">
      <div className="mb-4 px-5">
        <h3 className="text-sm font-semibold text-foreground-100">Connected integrations</h3>
        <p className="mt-1 text-xs text-foreground-400">12 services syncing to this workspace</p>
      </div>
 
      <Mask>
        <Mask.Fade direction="left" intensity={FADE_INTENSITY} />
        <Mask.Fade direction="right" intensity={FADE_INTENSITY} />
        <div className="flex gap-2 overflow-x-auto px-5" style={ROW_STYLE}>
          {INTEGRATIONS.map(({ id, label, icon }) => (
            <span
              className="flex shrink-0 items-center gap-2 rounded-sm border border-background-700 bg-background-800 px-3 py-2 text-sm text-foreground-300"
              key={id}
            >
              <Icon id={icon} size={16} className="text-foreground-400" />
              {label}
            </span>
          ))}
        </div>
      </Mask>
    </div>
  );
}
 

mask/marketing-headline

A hero title filled with a gradient via Mask.Gradient, which paints behind the text and clips to the glyphs so the fill follows the letterforms across line breaks.

Loading interactive preview
import { Mask } from 'ui-lab-components/mask';
 
// `Mask.Gradient` paints the gradient behind the text and clips it to the glyph
// shapes, so the fill follows the letterforms across line breaks and reflows
// with them. Anything nested inside inherits the transparent fill — keep the
// supporting copy and the buttons outside it.
const HEADLINE_GRADIENT =
  'linear-gradient(105deg, var(--foreground-50) 0%, var(--foreground-200) 32%, var(--accent-400) 68%, var(--accent-600) 100%)';
 
export default function Example() {
  return (
    <div className="rounded-md border border-background-700 bg-background-950 px-8 py-12 text-center">
      <span className="text-xs font-medium text-accent-400">
        Northwind 4.0
      </span>
 
      <Mask.Gradient gradient={HEADLINE_GRADIENT} className="mt-4">
        <h1 className="text-balance text-4xl font-semibold leading-tight tracking-tight">
          Ship a design system your whole team actually reaches for
        </h1>
      </Mask.Gradient>
 
      <p className="mt-4 text-sm leading-relaxed text-pretty text-foreground-400">
        Tokens, primitives, and documentation that stay in sync — one install, no
        build step, no lock-in.
      </p>
    </div>
  );
}
 

mask/photo-hero

as="child" applies the fade to the <img> itself, so a hero photo bleeds into the page background with no wrapper element to size, round, or re-introduce a hard edge.

Loading interactive preview
import { Mask } from 'ui-lab-components/mask';
 
// Stand-in artwork so the example ships without an external asset. In real use
// this is just the `src` of your photo.
const PHOTO_SRC = `data:image/svg+xml;utf8,${encodeURIComponent(
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500" preserveAspectRatio="none">
    <defs>
      <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
        <stop offset="0%" stop-color="#101a33"/>
        <stop offset="52%" stop-color="#3b4a78"/>
        <stop offset="100%" stop-color="#9a7590"/>
      </linearGradient>
    </defs>
    <rect width="800" height="500" fill="url(#sky)"/>
    <circle cx="612" cy="146" r="44" fill="#f2dcbb" opacity="0.92"/>
    <path d="M0 372 L152 244 L262 326 L384 206 L522 348 L646 276 L800 396 L800 500 L0 500 Z" fill="#233052"/>
    <path d="M0 438 L182 338 L342 428 L522 328 L702 418 L800 378 L800 500 L0 500 Z" fill="#141c31"/>
  </svg>`,
)}`;
 
export default function Example() {
  return (
    <div className="rounded-md bg-background-950">
      {/*
        `as="child"` puts the mask on the <img> itself instead of a wrapping div.
        No extra element means no extra box to size or round, and the photo's
        own object-fit still governs how it crops — a wrapper would need its own
        height, its own overflow, and would re-introduce the hard edge the fade
        is there to remove. `intensity={3}` fades the bottom 120px straight into
        the page background, which is why nothing sits behind the image.
      */}
      <Mask as="child">
        <Mask.Fade direction="bottom" intensity={3} />
        <img
          alt="Dusk over a layered mountain ridgeline"
          className="h-64 w-full rounded-t-md object-cover"
          src={PHOTO_SRC}
        />
      </Mask>
 
      <div className="relative -mt-16 px-7 pb-8">
        <span className="text-xs font-medium text-accent-400">
          Field report
        </span>
        <h2 className="mt-2 text-2xl font-semibold leading-tight text-foreground-50">
          Three days above the treeline
        </h2>
        <p className="mt-2 text-sm leading-relaxed text-pretty text-foreground-300">
          What a week of trail data looks like when the only uplink is a satellite
          messenger and every reading has to survive the trip home.
        </p>
      </div>
    </div>
  );
}
 

mask/policy-excerpt

A fixed-height excerpt whose overflow is cut rather than scrolled, using a fixed fade so intensity is a share of the box instead of a pixel length and the truncation reads as "continues below".

Loading interactive preview
import { Mask } from 'ui-lab-components/mask';
import { Icon } from 'ui-lab-components/icon';
 
// Nothing scrolls here — the box is a fixed height and the overflow is simply
// cut. `fixed` switches `intensity` from a pixel length to a share of the
// container (15% per step, capped at 50%), so 2 → the bottom 30% of the box
// dissolves no matter how tall the collapsed height ends up being. That is what
// keeps the truncation reading as "continues below" rather than "ends here".
const FADE_INTENSITY = 2;
 
export default function Example() {
  return (
    <article className="overflow-hidden rounded-md border border-background-700 bg-background-900">
      <header className="border-b border-background-700 px-5 py-3">
        <h3 className="text-sm font-semibold text-foreground-100">Data Processing Addendum</h3>
        <p className="mt-0.5 text-xs text-foreground-400">Version 3.1 · Effective 1 March 2026</p>
      </header>
 
      <Mask className="h-44">
        <Mask.Fade direction="bottom" intensity={FADE_INTENSITY} fixed />
        <div className="flex flex-col gap-3 px-5 py-4 text-sm leading-relaxed text-foreground-300">
          <p>
            This addendum applies whenever Northwind processes personal data on your behalf in the
            course of providing the Services. It supplements, and does not replace, the Master
            Subscription Agreement between the parties.
          </p>
          <p>
            You remain the controller of the personal data you upload. Northwind acts solely as a
            processor and will process that data only on your documented instructions, including
            with regard to transfers to a third country, unless required to do otherwise by law.
          </p>
          <p>
            Northwind maintains a published list of subprocessors and will give you at least thirty
            days' notice before adding or replacing any of them, during which you may object on
            reasonable data-protection grounds.
          </p>
        </div>
      </Mask>
 
      <footer className="px-5 pb-4">
        <button
          className="flex items-center gap-1.5 text-sm font-medium text-accent-400"
          type="button"
        >
          Read the full addendum
          <Icon id="chev" size={12} />
        </button>
      </footer>
    </article>
  );
}
 

mask/release-banner

Mask.Clip shapes a cover image with a CSS clip-path, clipping what the Mask paints without changing its layout box so the copy below keeps flowing from the banner's full height.

Loading interactive preview
import type { CSSProperties } from 'react';
import { Mask } from 'ui-lab-components/mask';
import { Icon } from 'ui-lab-components/icon';
 
// `Mask.Clip` takes any CSS `clip-path` — `polygon()` here, but `circle()`,
// `ellipse()`, and `inset()` all work the same way. It clips the Mask's painted
// box without changing its layout box, so the card below keeps flowing from the
// banner's full height and the diagonal just reveals the card behind it.
const ANGLED_BANNER = 'polygon(0 0, 100% 0, 100% 76%, 0 100%)';
 
// Stand-in artwork built from theme tokens so the example has no external
// asset; drop an <img> or a real background-image in here instead.
const COVER_STYLE = {
  background: [
    'radial-gradient(120% 90% at 18% 8%, color-mix(in srgb, var(--accent-500) 60%, transparent), transparent 62%)',
    'radial-gradient(100% 80% at 88% 24%, color-mix(in srgb, var(--info-500) 55%, transparent), transparent 66%)',
    'linear-gradient(155deg, var(--background-700), var(--background-950))',
  ].join(', '),
} as CSSProperties;
 
export default function Example() {
  return (
    <div className="overflow-hidden rounded-md border border-background-700 bg-background-900">
      <Mask className="h-44">
        <Mask.Clip shape={ANGLED_BANNER} />
        <div className="h-full w-full" style={COVER_STYLE} />
      </Mask>
 
      <div className="px-6 pb-6 pt-2">
        <span className="flex items-center gap-1.5 text-xs font-medium text-accent-400">
          <Icon id="tag" size={12} />
          Release notes
        </span>
        <h3 className="mt-2 text-lg font-semibold text-foreground-50">
          Usage-based billing is now generally available
        </h3>
        <p className="mt-1.5 text-sm leading-relaxed text-foreground-400">
          Meter any resource, preview invoices before they send, and reconcile against Stripe
          without a nightly job.
        </p>
        <span className="mt-3 flex items-center gap-1.5 text-sm font-medium text-accent-400">
          Read the announcement
          <Icon id="right" size={12} />
        </span>
      </div>
    </div>
  );
}