# Feature List Staggered Fade Up

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/feature-list-staggered-fade-up-on-scroll).

- Page, with a live preview: https://shadcn.exchange/r/weinc/feature-list-staggered-fade-up-on-scroll
- Screenshot: https://shadcn.exchange/shot/weinc/feature-list-staggered-fade-up-on-scroll/light.png
- Registry item: https://we.inc/r/feature-list-staggered-fade-up-on-scroll.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/feature-list-staggered-fade-up-on-scroll.json
```

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

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/feature-list-staggered-fade-up-on-scroll.tsx`

```tsx
import { useEffect, useRef } from "react";
import { useReducedMotion } from "framer-motion";

const ITEMS = [
  { title: "Drivetrain deep clean", text: "Chain, cassette and rings come off and go through the parts washer, so grit leaves the bike instead of moving around it." },
  { title: "Brake bleed and pads", text: "Fresh fluid for hydraulic brakes, new pads where the old ones are under a millimetre, and rotors checked for warp." },
  { title: "Wheel truing", text: "Both wheels go on the stand and get trued and tensioned by hand until they spin straight and sound even." },
  { title: "Bearings and headset", text: "Hubs, bottom bracket and headset are felt for roughness and regreased or replaced, never just sprayed." },
  { title: "Cables and housing", text: "Gear and brake cables are replaced if frayed, and housing is cut square so shifting feels crisp again." },
  { title: "Every bolt torqued", text: "Stem, bars, seatpost and rotors are set with a torque wrench to the maker's figure, not by feel." },
  { title: "Tyres and sealant", text: "Tyres are checked for cuts, tubeless sealant is topped up, and pressures are set for how you actually ride." },
  { title: "Test ride and notes", text: "A mechanic rides it round the yard, then writes down what was done and what to watch over the next few months." },
];
const SPEED = 26; // demo scroll in px per second before anyone touches the box; 0 turns it off

export default function FeatureListStaggeredFadeUpOnScroll() {
  const rootRef = useRef<HTMLElement>(null);
  const reduce = useReducedMotion();

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    let frame = 0;
    let last = 0;
    let pos = root.scrollTop;
    let seen = true;
    let done = !!reduce || !SPEED;
    const step = (t: number) => {
      const dt = last ? Math.min(t - last, 50) : 0;
      last = t;
      pos += (SPEED * dt) / 1000;
      if (pos >= root.scrollHeight - root.clientHeight) pos = 0;
      root.scrollTop = pos;
      frame = requestAnimationFrame(step);
    };
    const play = () => { if (!done && seen && !frame) { last = 0; frame = requestAnimationFrame(step); } };
    const stop = () => { cancelAnimationFrame(frame); frame = 0; };
    const vis = new IntersectionObserver(([e]) => { seen = e.isIntersecting; if (seen) play(); else stop(); });
    vis.observe(root);
    const hand = () => { done = true; stop(); root.setAttribute("data-touched", ""); };
    const evs = ["wheel", "touchstart", "pointerdown", "keydown"] as const;
    evs.forEach((n) => root.addEventListener(n, hand, { passive: true }));
    play();

    // Browsers without scroll-driven animations: reveal each item once as it enters the box.
    let io: IntersectionObserver | null = null;
    if (!reduce && !CSS.supports("animation-timeline: view()")) {
      root.setAttribute("data-fb", "");
      io = new IntersectionObserver((es) => es.forEach((e) => {
        if (e.isIntersecting) { e.target.classList.add("w1fad-in"); io?.unobserve(e.target); }
      }), { root, rootMargin: "0px 0px -10% 0px" });
      root.querySelectorAll(".w1fad-r").forEach((el) => io?.observe(el));
    }
    return () => {
      stop();
      vis.disconnect();
      io?.disconnect();
      evs.forEach((n) => root.removeEventListener(n, hand));
    };
  }, [reduce]);

  return (
    <section
      ref={rootRef}
      tabIndex={0}
      aria-label="What a full service covers, scrollable"
      className="w1fad relative h-[620px] overflow-y-auto overflow-x-hidden bg-[#f4efe6] text-[#231d16] focus-visible:outline focus-visible:outline-[3px] focus-visible:outline-offset-[-3px] focus-visible:outline-[#a4471f]"
    >
      <style>{`
        .w1fad { scrollbar-width: thin; }
        .w1fad-hint span { transition: opacity .5s ease; }
        .w1fad[data-touched] .w1fad-hint span { opacity: 0; }
        @media (min-width: 720px) { .w1fad-r:nth-child(even) { --c: 5%; --cd: 120ms; } }
        .w1fad-r > :nth-child(2) { --k: 3%; --kd: 80ms; }
        .w1fad-r > :nth-child(3) { --k: 6%; --kd: 160ms; }
        @keyframes w1fad-up { from { opacity: 0; transform: translateY(28px); } to { opacity: 1; transform: none; } }
        @supports (animation-timeline: view()) {
          .w1fad-r { view-timeline: --w1fad-i block; }
          .w1fad:not([data-fb]) .w1fad-r > * { animation: w1fad-up linear both; animation-timeline: --w1fad-i;
            animation-range: cover calc(var(--c, 0%) + var(--k, 0%) + 2%) cover calc(var(--c, 0%) + var(--k, 0%) + 24%); }
        }
        .w1fad[data-fb] .w1fad-r > * { opacity: 0; transform: translateY(28px);
          transition: opacity .6s ease, transform .8s cubic-bezier(.2,.7,.2,1); transition-delay: calc(var(--cd, 0ms) + var(--kd, 0ms)); }
        .w1fad[data-fb] .w1fad-in > * { opacity: 1; transform: none; }
        @media (prefers-reduced-motion: reduce) {
          .w1fad .w1fad-r > *, .w1fad[data-fb] .w1fad-r > * { animation: none !important; opacity: 1; transform: none; transition: none; }
        }
      `}</style>
      <div className="w1fad-hint pointer-events-none sticky top-0 z-[3] flex h-0 items-start justify-end" aria-hidden="true">
        <span className="mr-3 mt-3 rounded-full bg-[#231d16]/80 px-3 py-[5px] text-xs tracking-wide text-[#f4efe6]">Scroll inside</span>
      </div>
      <div className="mx-auto max-w-5xl px-5 pb-20 pt-[72px] sm:px-10 sm:pb-24 sm:pt-[88px]">
        <p className="text-xs font-bold uppercase tracking-[0.2em] text-[#a4471f]">Halden Cycle Works</p>
        <h2 className="mt-3.5 font-serif text-[clamp(2rem,5vw,3.25rem)] font-normal leading-[1.08] tracking-[-0.015em]">What a full service covers</h2>
        <p className="mt-[18px] max-w-[34rem] text-[17px] leading-relaxed text-[#4f4436]">
          Drop your bike off in the morning and collect it the same evening. Every full service runs through the same eight stations, in this order.
        </p>
        <ul className="mt-12 grid gap-x-16 md:grid-cols-2">
          {ITEMS.map((it, i) => (
            <li key={it.title} className="w1fad-r grid grid-cols-[52px_1fr] gap-x-2 border-t border-[#d9cfbf] pb-[30px] pt-[26px]">
              <span className="row-span-2 pt-1 text-[13px] font-bold tabular-nums text-[#a4471f]">{String(i + 1).padStart(2, "0")}</span>
              <h3 className="text-[19px] font-semibold">{it.title}</h3>
              <p className="mt-2 text-[15.5px] leading-relaxed text-[#4f4436]">{it.text}</p>
            </li>
          ))}
        </ul>
        <div className="mt-10 flex flex-wrap items-center justify-between gap-x-6 gap-y-4 rounded-2xl bg-[#ebe3d5] p-7">
          <p className="text-base leading-normal">Same-day turnaround when you drop off before ten.</p>
          <a href="#" className="inline-flex min-h-[46px] items-center rounded-full bg-[#231d16] px-[22px] text-[15px] font-semibold text-[#f4efe6] transition-colors hover:bg-[#3d3226] focus-visible:outline focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-[#a4471f]">
            Book a full service
          </a>
        </div>
      </div>
    </section>
  );
}
```
