# Tea List Rows Stretch With Scroll Speed

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/tea-list-rows-stretch-with-scroll-speed).

- Page, with a live preview: https://shadcn.exchange/r/weinc/tea-list-rows-stretch-with-scroll-speed
- Screenshot: https://shadcn.exchange/shot/weinc/tea-list-rows-stretch-with-scroll-speed/light.png
- Registry item: https://we.inc/r/tea-list-rows-stretch-with-scroll-speed.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/tea-list-rows-stretch-with-scroll-speed.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `components/we-inc/tea-list-rows-stretch-with-scroll-speed.tsx`

```tsx
import { useEffect, useRef } from "react";

const STRETCH = 0.035; // extra height per pixel scrolled in one frame (0.035 = 3.5%)
const MAX = 0.22; // the most a row may stretch
const EASE = 0.14; // how quickly rows follow the speed and settle back
const IDLE = 160; // the self-scroll demo swings this many px down and back every 4.8 s; 0 turns it off

const TEAS = [
  ["Darjeeling first flush", "India, spring picking", "6.20"],
  ["Assam breakfast", "India, malty and full", "4.80"],
  ["Sencha", "Japan, steamed green", "5.60"],
  ["Gyokuro", "Japan, shade grown", "8.40"],
  ["Tie Guan Yin", "China, rolled oolong", "6.90"],
  ["Keemun", "China, light smoke", "5.20"],
  ["Silver needle", "China, white buds", "9.10"],
  ["Ceylon high grown", "Sri Lanka, bright", "4.90"],
  ["Rooibos", "South Africa, red bush", "4.40"],
  ["Fresh mint", "From the garden", "3.80"],
];

export default function TeaListRowsStretchWithScrollSpeed() {
  const root = useRef<HTMLElement>(null);

  useEffect(() => {
    const el = root.current;
    if (!el) return;
    // The box is the scroller here. On a normal page, read scrollY and innerHeight instead.
    const sc = el;
    const rows = Array.from(el.querySelectorAll<HTMLElement>(".w3stl-row"));
    let tops: number[] = [];
    const measure = () => {
      const base = sc.getBoundingClientRect().top - sc.scrollTop;
      tops = rows.map((r) => { const b = r.getBoundingClientRect(); return b.top - base + b.height / 2; });
    };
    let demo = IDLE > 0;
    const used = () => { demo = false; el.setAttribute("data-used", ""); };
    const inputs = ["wheel", "touchstart", "pointerdown", "keydown"];
    inputs.forEach((e) => el.addEventListener(e, used, { passive: true }));
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let last = sc.scrollTop;
    let up = false;
    let cur = 0;
    let f = 0;
    let on = false;
    let t0 = 0;
    const tick = (now: number) => {
      if (demo && on) {
        sc.scrollTop = IDLE * (1 - Math.cos((now - t0) / 764));
      }
      const p = sc.scrollTop;
      const v = p - last;
      const h = sc.clientHeight;
      const mid = p + h / 2;
      last = p;
      if (v) up = v < 0;
      cur += (Math.min(MAX, Math.abs(v) * STRETCH) - cur) * EASE;
      if (cur < 0.001 && !v) cur = 0;
      rows.forEach((r, i) => {
        // rows near the middle of the box stretch most, rows at the edges barely
        const k = cur * Math.max(0, 1 - Math.abs(tops[i] - mid) / (h * 0.75));
        r.style.transformOrigin = up ? "50% 100%" : "50% 0";
        r.style.transform = k > 0.001 ? `scaleY(${(1 + k).toFixed(3)})` : "";
      });
      f = on && (cur || v || demo) ? requestAnimationFrame(tick) : 0;
    };
    const run = () => { if (!f && on) f = requestAnimationFrame(tick); };
    const io = new IntersectionObserver(([e]) => {
      on = e.isIntersecting;
      if (on) { measure(); t0 = performance.now(); run(); }
      else { cancelAnimationFrame(f); f = 0; }
    });
    if (!reduce) {
      sc.addEventListener("scroll", run, { passive: true });
      window.addEventListener("resize", measure);
      io.observe(el);
    }
    return () => {
      io.disconnect();
      cancelAnimationFrame(f);
      sc.removeEventListener("scroll", run);
      window.removeEventListener("resize", measure);
      inputs.forEach((e) => el.removeEventListener(e, used));
    };
  }, []);

  return (
    <section
      ref={root}
      tabIndex={0}
      aria-label="Hollow Lane Tea Room, tea list"
      className="w3stl relative h-[620px] overflow-y-auto overscroll-contain bg-[#eee3cf] text-[#2b2118] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-[#7a5a2e]"
    >
      <style>{`
        .w3stl-hint { background: linear-gradient(#eee3cf 60%, transparent); transition: opacity .5s ease; }
        .w3stl[data-used] .w3stl-hint { opacity: 0; }
        .w3stl-row { will-change: transform; }
        .w3stl-serif { font-family: Georgia, "Times New Roman", serif; }
        @media (prefers-reduced-motion: reduce) { .w3stl-row { transform: none !important; } .w3stl-hint { transition: none; } }
      `}</style>
      <p aria-hidden="true" className="w3stl-hint sticky top-0 z-[2] px-5 py-3 text-center text-[11px] font-semibold uppercase tracking-[0.22em] text-[#6a5843]">
        Scroll inside
      </p>
      <div className="mx-auto max-w-[52rem] px-5 pb-8 pt-4 text-center">
        <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[#7a5a2e]">Hollow Lane Tea Room</p>
        <h2 className="w3stl-serif mt-3 text-[clamp(32px,4.6vw,48px)] font-normal leading-[1.1] tracking-[-0.02em]">Loose leaf, brewed by the pot.</h2>
        <p className="mx-auto mt-3.5 max-w-[30rem] text-base leading-relaxed text-[#5a4a38]">
          Each pot serves two to three cups. Ask for a second steep of any leaf.
        </p>
      </div>
      <ol className="mx-auto max-w-[52rem] px-5 pb-7">
        {TEAS.map(([name, from, price]) => (
          <li key={name} className="w3stl-row grid grid-cols-[1fr_auto] items-baseline gap-x-4 border-t border-[#d3c4a8] py-[18px] last:border-b">
            <span className="w3stl-serif text-[clamp(22px,3vw,30px)] leading-[1.15]">{name}</span>
            <span className="row-start-2 pt-1 text-sm text-[#5a4a38]">{from}</span>
            <span className="row-span-2 self-center text-[17px] font-semibold tabular-nums">{price}</span>
          </li>
        ))}
      </ol>
      <p className="mx-auto max-w-[52rem] px-5 pb-[72px] text-center text-sm leading-relaxed text-[#5a4a38]">
        Prices per pot. Scones and the day's cake are listed on the board by the counter.
      </p>
    </section>
  );
}
```
