Skip to main content
Back to discovery

Live preview recorded from Dqnamo Kitchen; component and provider source published by JP / dqnamo. Preview: platform recorded · rights authorized.

Scroll Fade List

A compact list surface with a soft overflow fade.

dSource Landdqnamo

Why it stands out

A compact scrollable country list uses soft masks at its upper and lower edges. Scrolling moves rows through the surface while the fades keep clipped content from ending abruptly.

Prompt

"use client";

import { type ReactNode, useEffect, useRef } from "react";

type ScrollFadeListProps<TItem> = {
  getKey: (item: TItem) => string;
  items: readonly TItem[];
  renderItem: (item: TItem) => ReactNode;
  className?: string;
  maxFadeHeight?: number;
  scrollClassName?: string;
};

export function ScrollFadeList<TItem>({
  className = "relative overflow-hidden rounded-xl border bg-white [--scroll-fade-list-bg:white] [--scrollbar-gutter-width:10px]",
  getKey,
  items,
  maxFadeHeight = 76,
  renderItem,
  scrollClassName = "h-80 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]",
}: ScrollFadeListProps<TItem>) {
  const frameRef = useRef<HTMLDivElement>(null);
  const scrollRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const frameElement = frameRef.current;
    const scrollElement = scrollRef.current;
    let animationFrame = 0;

    if (!frameElement || !scrollElement) {
      return;
    }

    const updateFadeHeights = () => {
      const maxScrollTop = Math.max(
        0,
        scrollElement.scrollHeight - scrollElement.clientHeight,
      );
      const distanceFromTop = scrollElement.scrollTop;
      const distanceFromBottom = maxScrollTop - scrollElement.scrollTop;

      frameElement.style.setProperty(
        "--top-fade-height",
        `${Math.min(maxFadeHeight, Math.max(0, distanceFromTop))}px`,
      );
      frameElement.style.setProperty(
        "--bottom-fade-height",
        `${Math.min(maxFadeHeight, Math.max(0, distanceFromBottom))}px`,
      );
    };

    const scheduleFadeUpdate = () => {
      cancelAnimationFrame(animationFrame);
      animationFrame = requestAnimationFrame(updateFadeHeights);
    };

    updateFadeHeights();
    scrollElement.addEventListener("scroll", scheduleFadeUpdate, {
      passive: true,
    });

    const resizeObserver = new ResizeObserver(scheduleFadeUpdate);
    resizeObserver.observe(scrollElement);

    if (scrollElement.firstElementChild) {
      resizeObserver.observe(scrollElement.firstElementChild);
    }

    return () => {
      cancelAnimationFrame(animationFrame);
      scrollElement.removeEventListener("scroll", scheduleFadeUpdate);
      resizeObserver.disconnect();
    };
  }, [maxFadeHeight]);

  return (
    <div
      className={`${className} [--bottom-fade-height:0px] [--top-fade-height:0px] before:pointer-events-none before:absolute before:left-0 before:right-[var(--scrollbar-gutter-width)] before:top-0 before:h-[var(--top-fade-height)] before:bg-linear-to-b before:from-[var(--scroll-fade-list-bg)] before:to-transparent before:content-[''] after:pointer-events-none after:absolute after:bottom-0 after:left-0 after:right-[var(--scrollbar-gutter-width)] after:h-[var(--bottom-fade-height)] after:bg-linear-to-b after:from-transparent after:to-[var(--scroll-fade-list-bg)] after:content-['']`}
      ref={frameRef}
    >
      <div className={scrollClassName} ref={scrollRef}>
        <ul className="p-2">
          {items.map((item) => (
            <li className="rounded-md px-3 py-2" key={getKey(item)}>
              {renderItem(item)}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

Discovery vocabulary

Related by governed terms

Continue comparing

More from dqnamo