Swish UI
All componentsScroll Free

Scroll edge fade

Scrollable rows and lists fade out at an edge only while more content is hiding past it.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./demo.module.css";

const TYPES = ["All", "Deploys", "Errors", "Webhooks", "Billing", "Auth", "Cron", "Domains", "Storage", "Logs"];
const EVENTS = [
  { type: "Deploys", title: "Production deploy ready", meta: "main", time: "2m" },
  { type: "Errors", title: "TypeError in /api/orders", meta: "3 users", time: "6m" },
  { type: "Webhooks", title: "invoice.paid delivered", meta: "200", time: "9m" },
  { type: "Auth", title: "New sign in from Lisbon", meta: "SSO", time: "14m" },
  { type: "Deploys", title: "Preview deploy ready", meta: "feat/search", time: "21m" },
  { type: "Cron", title: "Nightly export finished", meta: "48s", time: "1h" },
  { type: "Billing", title: "Usage passed 80% of plan", meta: "Pro", time: "2h" },
  { type: "Domains", title: "Certificate renewed", meta: "app.halden.dev", time: "3h" },
  { type: "Storage", title: "Bucket reached 40 GB", meta: "assets", time: "5h" },
  { type: "Errors", title: "Timeout in image resize", meta: "1 user", time: "6h" },
  { type: "Logs", title: "Drain connected", meta: "us-east", time: "8h" },
  { type: "Webhooks", title: "customer.created retried", meta: "503", time: "9h" },
];

export default function Demo() {
  const row = useRef<HTMLDivElement>(null);
  const list = useRef<HTMLUListElement>(null);
  const [type, setType] = useState("All");
  const shown = EVENTS.filter((e) => type === "All" || e.type === type);

  // Fallback for browsers without scroll driven animations: flag each edge that has more content behind it.
  useEffect(() => {
    if (CSS.supports("animation-timeline: scroll()")) return;
    const els = [row.current, list.current].filter((el): el is HTMLDivElement | HTMLUListElement => !!el);
    const update = (el: HTMLElement) => {
      const across = el.scrollWidth > el.clientWidth;
      const pos = across ? el.scrollLeft : el.scrollTop;
      const max = across ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
      el.toggleAttribute("data-start", pos > 1);
      el.toggleAttribute("data-end", pos < max - 1);
    };
    const handlers = els.map((el) => {
      const fn = () => update(el);
      el.addEventListener("scroll", fn, { passive: true });
      fn();
      return fn;
    });
    const ro = new ResizeObserver(() => els.forEach(update));
    els.forEach((el) => {
      ro.observe(el);
      if (el.firstElementChild) ro.observe(el.firstElementChild);
    });
    return () => {
      ro.disconnect();
      els.forEach((el, i) => el.removeEventListener("scroll", handlers[i]));
    };
  }, [type]);


  return (
    <div className={styles.root}>
      <section className={styles.panel} aria-label="Activity">
        <header className={styles.head}>
          <p className={styles.title}>Activity</p>
          <span className={styles.count}>{shown.length} events</span>
        </header>
        <div ref={row} className={`${styles.fade} ${styles.chips}`} role="group" aria-label="Filter by type">
          {TYPES.map((t) => (
            <button key={t} type="button" className={styles.chip} aria-pressed={t === type} onClick={() => setType(t)}>
              {t}
            </button>
          ))}
        </div>
        {/* A fresh list per filter: it starts at the top and its scroll timeline starts over. */}
        <ul key={type} ref={list} className={`${styles.fade} ${styles.list}`} tabIndex={0} aria-label={`${type} events`}>
          {shown.map((e) => (
            <li key={e.title} className={styles.item}>
              <span className={styles.dot} data-type={e.type} aria-hidden="true" />
              <span className={styles.text}>
                <span className={styles.itemTitle}>{e.title}</span>
                <span className={styles.meta}>{e.type} · {e.meta}</span>
              </span>
              <span className={styles.time}>{e.time}</span>
            </li>
          ))}
        </ul>
      </section>
    </div>
  );
}

demo.module.css

/* Two registered lengths, the fade at the start and at the end, so they can animate and transition. */
@property --fade-start { syntax: "<length>"; inherits: false; initial-value: 0px; }
@property --fade-end { syntax: "<length>"; inherits: false; initial-value: 0px; }

.root {
  container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 16px;
  box-sizing: border-box; background: var(--surface);
}
.panel {
  display: grid; grid-template-rows: auto auto minmax(0, 1fr); width: min(520px, 100%); height: 100%; max-height: 400px;
  border-radius: 16px; border: 1px solid var(--line); background: var(--white); overflow: hidden;
}
.head { display: flex; align-items: baseline; gap: 8px; padding: 12px 16px 4px; }
.title { margin: 0; font-size: 14px; font-weight: 650; }
.count { margin-left: auto; font-size: 12px; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }

/* The fade itself: a mask whose edges are only as deep as the matching custom property. */
.fade {
  --size: 44px;
  -webkit-mask-image: linear-gradient(var(--dir), transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent);
  mask-image: linear-gradient(var(--dir), transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent);
  transition: --fade-start .25s, --fade-end .25s;
}
.fade[data-start] { --fade-start: var(--size); }
.fade[data-end] { --fade-end: var(--size); }
.chips { --dir: to right; }
.list { --dir: to bottom; --size: 56px; }

/* Where supported, scroll position drives the fades: the start fade grows over the first few pixels of
   scroll, the end fade shrinks over the last few. With nothing to scroll the timeline is inactive and both stay 0. */
@supports (animation-timeline: scroll()) {
  .fade { animation: reveal-start linear both, hide-end linear both; animation-range: 0 var(--size), calc(100% - var(--size)) 100%; }
  .chips { animation-timeline: scroll(self inline), scroll(self inline); }
  .list { animation-timeline: scroll(self block), scroll(self block); }
}
@keyframes reveal-start { from { --fade-start: 0px; } to { --fade-start: var(--size); } }
@keyframes hide-end { from { --fade-end: var(--size); } to { --fade-end: 0px; } }

.chips {
  display: flex; gap: 6px; padding: 8px 16px 10px; overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain;
  border-bottom: 1px solid var(--line);
}
.chips::-webkit-scrollbar { display: none; }
.chip {
  flex: none; min-height: 34px; padding: 0 13px; border-radius: 999px; border: 1px solid var(--line); background: var(--white);
  color: var(--muted); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer;
  transition: background-color .25s, color .25s, border-color .25s, transform .18s var(--out);
}
.chip:hover { color: var(--ink); }
.chip:active { transform: scale(.95); }
.chip[aria-pressed="true"] { background: var(--ink); border-color: var(--ink); color: var(--white); }
.chip:focus-visible, .list:focus-visible { outline: 2px solid var(--accent-text); outline-offset: -2px; }
.chip:focus-visible { outline-offset: 2px; }

.list { list-style: none; margin: 0; padding: 6px 8px; overflow-y: auto; overscroll-behavior-y: contain; scrollbar-width: thin; }
.item { display: flex; align-items: center; gap: 10px; min-height: 42px; padding: 4px 8px; border-radius: 10px; animation: item-in .4s var(--out) both; }
.item:hover { background: var(--surface); }
@keyframes item-in { from { opacity: 0; translate: 0 6px; } }
.dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--accent-soft); }
.dot[data-type="Errors"] { background: var(--ink); }
.dot[data-type="Deploys"] { background: var(--accent); }
.dot[data-type="Billing"], .dot[data-type="Storage"] { background: var(--sand); box-shadow: inset 0 0 0 1.5px var(--muted); }
.text { flex: 1; min-width: 0; display: grid; line-height: 1.25; }
.itemTitle { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.meta { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.time { flex: none; font-size: 11.5px; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }

@container (max-height: 300px) {
  .root { padding: 10px; }
  .head { display: none; }
  .chips { padding-top: 10px; }
  .item { min-height: 36px; }
  .list { --size: 40px; }
}
@container (max-width: 360px) {
  .chips { padding-inline: 12px; }
  .meta { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .fade { transition: none; }
  .item { animation: none; }
}

Use it for

  • Filter chip rows and tab strips that overflow on phones
  • Short scrolling lists inside cards, menus and side panels
  • Any inner scroller whose scrollbar is hidden or easy to miss

Avoid

  • The main page scroll, where the browser already shows position
  • Scrollers with text right at the edge that must stay fully readable

How it works

Each scroller has a mask-image gradient whose start and end depths are two registered custom properties. Scroll driven animations (animation-timeline: scroll(self)) grow the start fade over the first few pixels of scroll and shrink the end fade over the last few, so with nothing to scroll the timeline is inactive and no fade shows. Where animation-timeline is missing, a passive scroll listener and a ResizeObserver toggle data-start and data-end attributes instead, and the registered properties transition between the two depths.

Accessibility

The fades are purely visual, so all content stays in the reading order. Filter chips are toggle buttons with aria-pressed and the list is focusable for keyboard scrolling. Reduced motion keeps the fades, which show state rather than movement, and drops the transitions and the list entrance.

Found on