Luca Perullo
Components

Component

LiveNew

Parallax Lane

Wrapper client component: ascolta lo scroll passive, applica un translate3d Y al figlio = tanh(scrollY × multiplier / max) × max. rAF-throttled, asintotico (no snap ai bordi), slack 22% sopra/sotto così non si vedono mai bordi vuoti. Pensato per parallax molto leggera sui marquee delle side columns.

MotionClientScroll
Open in ClaudeSorgente

Anteprima01

Anteprima non ancora pubblicata. Per ora puoi copiare il prompt qui sotto e rigenerare il componente con il tuo brand.

Note Curva tanh asintotica: lineare per scrollY piccoli, tende ad ±maxOffsetPx senza mai raggiungerlo. Niente snap al boundary. Il transform viene scritto direttamente sull'elemento (no CSS variable) così i siblings non rifanno style-recalc. prefers-reduced-motion: skip totale.

Prompt LLM02

Incolla in Claude o ChatGPT per generare la tua variante. Include il contesto del brand, i token e i vincoli del progetto.

Prompt · parallax-lane
Open in Claude
Sei un senior frontend engineer. Stai lavorando su un sito Next.js 16 + React 19 + Tailwind v4 in italiano, look chanhdai-inspired: colonna stretta 672px, Geist Sans + Geist Mono, hairline 1px, divisori a stripe diagonale, palette zinc.

Token CSS disponibili: --bg, --bg-alt, --fg, --fg-muted, --fg-soft, --border, --border-strong, --accent. Usa SEMPRE queste variabili tramite le utility tailwind generate (bg-bg, text-fg-muted, border-border, ecc.). Helper "cn" da "@/lib/utils". Niente librerie UI extra: solo lucide-react e tailwind-merge.

Genera un client component <ParallaxLane>: wrapper che applica un piccolo translateY scroll-tied al figlio.
Props:
- multiplier?: number (default 0.04) — scrollY * multiplier = offset raw. Negativo = direzione opposta allo scroll.
- maxOffsetPx?: number (default 110) — asintoto del tanh.
- slackPct?: number (default 20) — slack verticale sopra/sotto, in percentuale dell'altezza outer.
- children: ReactNode
- className?: string

Comportamento:
- "use client".
- useEffect: registra scroll listener passive con rAF throttle (un solo frame in volo). Apply iniziale per cogliere reload con scroll già non-zero.
- Skip se matchMedia "(prefers-reduced-motion: reduce)" matches.
- Cleanup: removeEventListener + cancelAnimationFrame.
- offset = Math.tanh(scrollY * multiplier / maxOffsetPx) * maxOffsetPx (asintoto, no snap).
- Scrivi il transform direttamente sull'elemento (innerRef.style.transform = "translate3d(0, Ypx, 0)") — niente CSS variable per evitare recalc inheritati.

Layout: container outer "relative h-full overflow-hidden". Inner absolute inset-x-0 con top: -slackPct% bottom: -slackPct% (slack equa sopra/sotto), will-change-transform. Il children dentro l'inner.

Constraints: zero dipendenze JS extra, transform-only, accessibile (decorativo, aria-hidden gestito dal parent), funziona dentro <MarqueeVertical>.

Output: file completo .tsx (con "use client").

Codice03

Sorgente live di src/components/parallax-lane.tsx. Letto a request-time dal repo — sempre allineato al sito.

tsxsrc/components/parallax-lane.tsx
"use client";

import { useEffect, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "./use-reduced-motion";

export type ParallaxLaneProps = {
    /**
     * Multiplier applied to scrollY (px) to produce the lane's translateY.
     * Use small values (~0.02–0.07). Negative values move opposite to scroll.
     */
    multiplier?: number;
    /**
     * Maximum absolute translation in pixels — clamped so we never exceed
     * the slack space wrapping the lane. Default 110.
     */
    maxOffsetPx?: number;
    /**
     * Vertical slack as a percentage of the lane height. The inner translates
     * within this slack — set on both sides of the wrapper as `inset-y` extra
     * space. Default 20 (so inner = 140% of outer height).
     */
    slackPct?: number;
    children: ReactNode;
    className?: string;
};

/**
 * Wraps a marquee lane with a small scroll-tied parallax translation.
 *
 * Motion notes (Emil playbook):
 *  - Scroll is rAF-throttled with a single requestAnimationFrame cycle —
 *    no work happens between frames if the user isn't scrolling.
 *  - We write `transform` directly to the element (not a CSS variable) so
 *    siblings don't recompute styles. Only this node's compositor layer
 *    moves — true GPU work.
 *  - The lane has slack (default 20%) above and below it, so the parallax
 *    translation never exposes empty space at the gutter edges.
 *  - The translation uses a tanh asymptote, not a clamp: linear-ish at
 *    small scrollY, smoothly approaching ±maxOffsetPx as scrollY grows.
 *    No snap at the boundary, no "stuck" feel on very long pages.
 *  - `prefers-reduced-motion` users get no parallax — the marquee itself
 *    is also collapsed by the global rule.
 */
export function ParallaxLane({
    multiplier = 0.04,
    maxOffsetPx = 110,
    slackPct = 20,
    children,
    className,
}: ParallaxLaneProps) {
    const innerRef = useRef<HTMLDivElement | null>(null);

    useEffect(() => {
        if (typeof window === "undefined") return;

        if (prefersReducedMotion()) {
            return;
        }

        let frame = 0;
        let scheduled = false;

        const apply = () => {
            scheduled = false;
            const el = innerRef.current;
            if (!el) return;
            // tanh asymptote: linear-ish at small scrollY, smoothly approaches
            // ±maxOffsetPx as scrollY grows. No snap at the boundary.
            const raw = window.scrollY * multiplier;
            const y = Math.tanh(raw / maxOffsetPx) * maxOffsetPx;
            el.style.transform = `translate3d(0, ${y.toFixed(2)}px, 0)`;
        };

        const onScroll = () => {
            if (scheduled) return;
            scheduled = true;
            frame = requestAnimationFrame(apply);
        };

        // Apply once on mount in case the page loaded already-scrolled.
        apply();
        window.addEventListener("scroll", onScroll, { passive: true });
        return () => {
            window.removeEventListener("scroll", onScroll);
            if (frame) cancelAnimationFrame(frame);
        };
    }, [multiplier, maxOffsetPx]);

    // The outer crops slack; the inner is taller than outer by 2× slackPct
    // and centred (inset-y: -slackPct%).
    const insetY = `-${slackPct}%`;

    return (
        <div className={cn("relative h-full overflow-hidden", className)}>
            <div
                ref={innerRef}
                className="absolute inset-x-0 will-change-transform"
                style={{
                    top: insetY,
                    bottom: insetY,
                    transform: "translate3d(0, 0, 0)",
                }}
            >
                {children}
            </div>
        </div>
    );
}

Uso tipico04

tsx
<ParallaxLane multiplier={0.04} slackPct={22}>
  <MarqueeVertical ...>{children}</MarqueeVertical>
</ParallaxLane>

Dipendenze05

npm
  • tailwind-merge
  • clsx
Interno
  • @/lib/utils#cn

Ti è servito? Dimmelo, oppure proponi il prossimo componente.