Patrones
BetaScroll reveal y carousel — los patrones de movimiento canónicos, listos para copiar.
Los patrones son recetas completas: combinan easing + duración + estructura.
Copiá el código tal cual — ya incluye prefers-reduced-motion y limpieza
correcta en React.
Reveal on scroll
El patrón de entrada por scroll: fade-up de 32px con ease-enter, una sola
vez por elemento (once: true). Usa GSAP ScrollTrigger con useGSAP, que
limpia los triggers al desmontar.
Scrolleá dentro de este panel ↓
Inscripciones
Entra con fade-up al cruzar el umbral del scroll.
Calendario
Entra con fade-up al cruzar el umbral del scroll.
Materias
Entra con fade-up al cruzar el umbral del scroll.
Biblioteca
Entra con fade-up al cruzar el umbral del scroll.
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
import { easing } from "@/tokens";
gsap.registerPlugin(ScrollTrigger, useGSAP);
/**
* Patrón: reveal on scroll. Las cards entran con fade-up (una sola vez)
* cuando cruzan el 85% del scroller. En tu página, omití `scroller`
* (por defecto usa el viewport) — acá apunta al contenedor del demo.
*/
export default function ScrollReveal() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(
() => {
gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => {
gsap.utils.toArray<HTMLElement>("[data-reveal]").forEach((card) => {
gsap.from(card, {
y: 32,
autoAlpha: 0,
duration: 0.6,
ease: easing.enter.value.gsap,
scrollTrigger: {
trigger: card,
scroller: scope.current, // ← omitir en una página real
start: "top 85%",
once: true,
},
});
});
});
},
{ scope },
);
return (
<div ref={scope} className="h-64 w-full overflow-y-auto rounded-md border border-border">
<p className="p-4 text-sm text-fg-muted">Scrolleá dentro de este panel ↓</p>
<div className="flex flex-col gap-4 p-4 pt-40">
{["Inscripciones", "Calendario", "Materias", "Biblioteca"].map((title) => (
<div
key={title}
data-reveal
className="rounded-lg border border-primary-border bg-primary-subtle p-5"
>
<h3 className="font-medium text-primary-active">{title}</h3>
<p className="mt-1 text-sm text-fg-muted">
Entra con fade-up al cruzar el umbral del scroll.
</p>
</div>
))}
</div>
</div>
);
}Carousel
Scroll-snap nativo: el browser maneja el gesto (touch y trackpad incluidos), los botones solo empujan. Sin librerías y accesible por defecto.
"use client";
import { useRef } from "react";
/**
* Patrón: carousel con scroll-snap nativo. El browser maneja el gesto
* (táctil y trackpad incluidos); los botones solo empujan el scroll.
* Sin librerías, accesible, y respeta reduced-motion vía scroll-behavior.
*/
export default function Carousel() {
const track = useRef<HTMLDivElement>(null);
const page = (dir: 1 | -1) => {
const el = track.current!;
el.scrollBy({ left: dir * el.clientWidth * 0.85, behavior: "smooth" });
};
return (
<div className="w-full">
<div
ref={track}
className="flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth motion-reduce:scroll-auto"
aria-label="Carrusel de novedades"
>
{[1, 2, 3, 4, 5].map((n) => (
<div
key={n}
className="flex h-40 w-4/5 shrink-0 snap-start items-center justify-center rounded-lg border border-primary-border bg-primary-subtle text-2xl font-medium text-primary-active sm:w-3/5"
>
{n}
</div>
))}
</div>
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
onClick={() => page(-1)}
aria-label="Anterior"
className="size-9 rounded-full border border-border text-fg-muted transition-colors hover:bg-surface hover:text-fg"
>
←
</button>
<button
type="button"
onClick={() => page(1)}
aria-label="Siguiente"
className="size-9 rounded-full border border-border text-fg-muted transition-colors hover:bg-surface hover:text-fg"
>
→
</button>
</div>
</div>
);
}Cuándo NO animar
- Contenido crítico que el usuario vino a buscar: mostralo ya.
- Listas largas: reveal en los primeros elementos, el resto estático.
- Nunca bloquees input esperando que termine una animación.