Skip to content
Sections

Sections

SidewaysScroll

Row of cards that pins in place and pans sideways as the page scrolls.

npx bezel-add add sideways-scroll

packages/ui/src/sections/SidewaysScroll.tsx · 234 lines

"use client";

import {
  Children,
  isValidElement,
  useEffect,
  useRef,
  useState,
  useSyncExternalStore,
  type CSSProperties,
  type ReactNode,
} from "react";

/*
 * SidewaysScroll: a row of cards that pins to the viewport and pans one pixel
 * sideways for every pixel scrolled down, then holds on the last card for a
 * moment before the page moves on.
 *
 * It needs no scroll library. The section is made as tall as the pan, an inner
 * block sticks to the top, and a single passive scroll listener maps the page's
 * scroll position to the row's translate. Tabbing to a card that is out of view
 * scrolls the page to the point where that card is in view, so the keyboard
 * never lands on something hidden.
 *
 * Narrow or short viewports, reduced motion and rows that already fit get a
 * plain snap scroller instead.
 */

const CSS = `
.bz-ss{position:relative;color:var(--bz-ink,#0a0a0a);font-family:var(--bz-font-sans,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif)}
.bz-ss *,.bz-ss *::before,.bz-ss *::after{box-sizing:border-box}
.bz-ss-sticky{display:flex;flex-direction:column;justify-content:center;gap:20px;padding-block:24px}
.bz-ss[data-layout="pinned"] .bz-ss-sticky{position:sticky;top:var(--bz-ss-top,0px);height:calc(100vh - var(--bz-ss-top,0px));overflow:hidden}
.bz-ss-head{padding-inline:var(--bz-ss-pad,24px)}
.bz-ss-row{position:relative;-webkit-mask-image:linear-gradient(90deg,#000 calc(100% - min(12%,96px)),transparent);mask-image:linear-gradient(90deg,#000 calc(100% - min(12%,96px)),transparent)}
.bz-ss[data-end="true"] .bz-ss-row{-webkit-mask-image:none;mask-image:none}
.bz-ss-track{position:relative;display:flex;gap:16px;padding:4px var(--bz-ss-pad,24px) 16px;overflow-x:auto;scroll-snap-type:x mandatory;scroll-padding-inline:var(--bz-ss-pad,24px);overscroll-behavior-x:contain;outline:none}
.bz-ss-track:focus-visible{outline:2px solid var(--bz-focus-ring,#912c22);outline-offset:-2px;border-radius:12px}
.bz-ss[data-layout="pinned"] .bz-ss-track{overflow:visible;scroll-snap-type:none;will-change:transform}
.bz-ss-item{flex:none;scroll-snap-align:start}
.bz-ss-hint{display:none;align-self:flex-start;align-items:center;gap:10px;margin-inline:var(--bz-ss-pad,24px);font-size:0.8125rem;font-weight:500;line-height:1;color:var(--bz-ink-muted,#4a4a4c);transition:opacity 300ms var(--bz-ease-out,cubic-bezier(0.23,1,0.32,1))}
.bz-ss[data-layout="pinned"] .bz-ss-hint{display:inline-flex}
.bz-ss-mouse{position:relative;width:18px;height:28px;border:1.5px solid currentColor;border-radius:10px}
.bz-ss-mouse::after{content:"";position:absolute;top:6px;left:50%;width:3px;height:6px;margin-left:-1.5px;border-radius:2px;background:currentColor;animation:bz-ss-wheel 1.6s var(--bz-ease-in-out,cubic-bezier(0.77,0,0.175,1)) infinite}
.bz-ss[data-onscreen="false"] .bz-ss-mouse::after,.bz-ss[data-hint="off"] .bz-ss-mouse::after{animation-play-state:paused}
@keyframes bz-ss-wheel{0%{transform:translateY(0);opacity:1}70%{transform:translateY(8px);opacity:0}100%{transform:translateY(0);opacity:0}}
@media (prefers-reduced-motion:reduce){.bz-ss-mouse::after{animation:none}.bz-ss-hint{transition:none}}
`;

const RM_QUERY = "(prefers-reduced-motion: reduce)";
const subscribeReducedMotion = (onChange: () => void) => {
  const query = window.matchMedia(RM_QUERY);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
};
const readReducedMotion = () => window.matchMedia(RM_QUERY).matches;
const serverReducedMotion = () => false;

/** Extra scroll, as a share of the pan, spent holding on the last card. */
const HOLD = 0.18;

export type SidewaysScrollProps = {
  /** One card per child. */
  children: ReactNode;
  /** Names the list of cards for assistive tech. */
  label: string;
  /** Pinned above the row, and panned with it out of the way of nothing. */
  heading?: ReactNode;
  /** Distance from the top of the viewport to pin at, for a sticky header. */
  pinOffset?: number;
  /** Below this viewport width the row is a snap scroller. */
  minWidth?: number;
  /** Below this viewport height the row is a snap scroller. */
  minHeight?: number;
  /** Text beside the scroll cue while pinned. */
  hint?: string;
  className?: string;
};

export function SidewaysScroll({
  children,
  label,
  heading,
  pinOffset = 0,
  minWidth = 640,
  minHeight = 420,
  hint = "Scroll",
  className = "",
}: SidewaysScrollProps) {
  const reduced = useSyncExternalStore(subscribeReducedMotion, readReducedMotion, serverReducedMotion);
  const [mode, setMode] = useState<"scroller" | "pinned">("scroller");
  const sectionRef = useRef<HTMLElement>(null);
  const rowRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);
  const hintRef = useRef<HTMLDivElement>(null);
  const modeRef = useRef(mode);
  modeRef.current = mode;

  useEffect(() => {
    const section = sectionRef.current;
    const row = rowRef.current;
    const track = trackRef.current;
    if (!section || !row || !track) return;

    let distance = 0;
    let start = 0;
    let raf = 0;

    const measure = () => {
      const items = track.children;
      const lastItem = items[items.length - 1] as HTMLElement | undefined;
      const padRight = parseFloat(getComputedStyle(track).paddingRight) || 0;
      const contentRight = lastItem ? lastItem.offsetLeft + lastItem.offsetWidth + padRight : 0;
      distance = Math.max(0, Math.round(contentRight - row.clientWidth));
      const allowPan =
        !readReducedMotion() && window.innerWidth >= minWidth && window.innerHeight >= minHeight && distance > 0;
      const nextMode = allowPan ? "pinned" : "scroller";
      if (nextMode !== modeRef.current) {
        modeRef.current = nextMode;
        setMode(nextMode);
      }
      if (nextMode === "pinned") {
        const stickyHeight = window.innerHeight - pinOffset;
        section.style.height = `${Math.round(stickyHeight + distance * (1 + HOLD))}px`;
        start = section.getBoundingClientRect().top + window.scrollY - pinOffset;
      } else {
        section.style.height = "";
        track.style.transform = "";
      }
      update();
    };

    const update = () => {
      raf = 0;
      if (modeRef.current !== "pinned") {
        section.dataset.end = String(track.scrollLeft + track.clientWidth >= track.scrollWidth - 4);
        return;
      }
      const travel = Math.max(0, Math.min(window.scrollY - start, distance * (1 + HOLD)));
      const x = Math.min(travel, distance);
      track.style.transform = `translate3d(${-x}px, 0, 0)`;
      section.dataset.end = String(x >= distance - 1);
      const showHint = travel < 24;
      section.dataset.hint = showHint ? "on" : "off";
      if (hintRef.current) hintRef.current.style.opacity = showHint ? "1" : "0";
    };

    const onScroll = () => {
      if (!raf) raf = requestAnimationFrame(update);
    };

    const onFocusIn = (event: FocusEvent) => {
      if (modeRef.current !== "pinned") return;
      const item = (event.target as Element | null)?.closest?.(".bz-ss-item") as HTMLElement | null;
      if (!item || !track.contains(item)) return;
      const current = Math.max(0, Math.min(window.scrollY - start, distance));
      const left = item.offsetLeft - current;
      const right = left + item.offsetWidth;
      if (left >= 0 && right <= row.clientWidth) return;
      const padLeft = parseFloat(getComputedStyle(track).paddingLeft) || 0;
      const wanted = Math.max(0, Math.min(item.offsetLeft - padLeft, distance));
      // After the browser's own scroll-into-view has run.
      requestAnimationFrame(() => window.scrollTo({ top: start + wanted, behavior: "auto" }));
    };

    const ro = new ResizeObserver(() => measure());
    ro.observe(row);
    ro.observe(track);
    window.addEventListener("resize", measure);
    window.addEventListener("scroll", onScroll, { passive: true });
    track.addEventListener("scroll", onScroll, { passive: true });
    track.addEventListener("focusin", onFocusIn);

    let io: IntersectionObserver | null = null;
    if ("IntersectionObserver" in window) {
      io = new IntersectionObserver(([entry]) => {
        section.dataset.onscreen = String(entry.isIntersecting);
      });
      io.observe(section);
    }

    measure();

    return () => {
      if (raf) cancelAnimationFrame(raf);
      ro.disconnect();
      io?.disconnect();
      window.removeEventListener("resize", measure);
      window.removeEventListener("scroll", onScroll);
      track.removeEventListener("scroll", onScroll);
      track.removeEventListener("focusin", onFocusIn);
      section.style.height = "";
      track.style.transform = "";
    };
  }, [reduced, pinOffset, minWidth, minHeight, mode]);

  const items = Children.toArray(children);

  return (
    <>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <section
        ref={sectionRef}
        className={`bz-ss ${className}`.trim()}
        data-layout={mode}
        aria-label={heading ? undefined : label}
        style={{ ["--bz-ss-top" as string]: `${pinOffset}px` } as CSSProperties}
      >
        <div className="bz-ss-sticky">
          {heading ? <div className="bz-ss-head">{heading}</div> : null}
          <div ref={rowRef} className="bz-ss-row">
            <div
              ref={trackRef}
              className="bz-ss-track"
              role="list"
              aria-label={label}
              tabIndex={mode === "scroller" ? 0 : undefined}
            >
              {items.map((child, index) => (
                <div key={isValidElement(child) && child.key != null ? child.key : index} className="bz-ss-item" role="listitem">
                  {child}
                </div>
              ))}
            </div>
          </div>
          <div ref={hintRef} className="bz-ss-hint" aria-hidden="true">
            <span className="bz-ss-mouse" />
            {hint}
          </div>
        </div>
      </section>
    </>
  );
}