Pilha que se espalha na rolagem
Pilha de cards que se espalha conforme a rolagem avança, numa seção presa de 350 vh, com paralaxe do cursor.
usa o ROL-01
O código
/* ===== espalhar.css ================================================================ */
/* ROL-01 · Pilha que se espalha na rolagem — reescrita do original do 21st.dev sem React nem Motion.
Os itens entram sobrepostos no centro e, conforme a seção sobe da base da tela até o meio, cada um
vai para a sua coluna.
Duas diferenças do original, as duas deliberadas:
- **sem pin.** O original prende uma seção de 350 vh e come três telas de rolagem; aqui o efeito
acompanha a rolagem natural, sem alongar a página.
- **generalizado.** O original tem uma pilha fixa; aqui vale para N itens numa grade qualquer.
`--espalha` vai de 0 (empilhado) a 1 (layout final). **O padrão é 1**: sem JS, em tela estreita ou
com "reduzir movimento", o layout final é o que aparece — a falha mostra, não esconde. */
.c-espalhar {
--c-rol-raio: 24px;
--c-rol-card-fundo: #141414;
--c-rol-card-linha: rgba(235, 235, 235, .12);
--c-rol-giro: 3deg; /* o giro do item quando empilhado */
--c-rol-peek: 32px; /* o quanto o item de trás espia de lado, para não ficar escondido */
display: grid;
gap: 24px;
}
.c-espalhar__item {
margin: 0;
border: 1px solid var(--c-rol-card-linha);
border-radius: var(--c-rol-raio);
background: var(--c-rol-card-fundo);
}
@media (min-width: 1024px) {
.c-espalhar { position: relative; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); column-gap: 48px; align-items: center; }
/* cada item anda --dx (escrito pelo JS) multiplicado pelo que falta para espalhar.
O z-index decresce: o primeiro fica por cima da pilha. */
.c-espalhar__item {
position: relative;
will-change: transform;
transform: translateX(calc(var(--dx, 0px) * (1 - var(--espalha, 1))))
rotate(calc(var(--c-rol-giro) * (1 - var(--espalha, 1)) * var(--sinal, 1)));
}
.c-espalhar__item:nth-child(1) { z-index: 3; --sinal: -1; }
.c-espalhar__item:nth-child(2) { z-index: 2; --sinal: 1; transform:
translate(calc((var(--dx, 0px) + var(--c-rol-peek)) * (1 - var(--espalha, 1))), calc(24px * (1 - var(--espalha, 1))))
rotate(calc(var(--c-rol-giro) * .67 * (1 - var(--espalha, 1)))); }
.c-espalhar__item:nth-child(n+3) { z-index: 1; --sinal: 1; }
}
@media (prefers-reduced-motion: reduce) {
.c-espalhar__item { transform: none !important; }
}
/* ===== espalhar.js ================================================================= */
/* ROL-01 · Espalhamento na rolagem. Mede o quanto cada item precisa andar para ficar no centro do
contêiner (--dx) e escreve --espalha de 0 a 1 conforme a seção sobe da base da tela até o meio.
Só a partir de 1024 px e sem "reduzir movimento"; fora disso remove --espalha e o CSS mostra o
layout final.
Mede DE NOVO no primeiro quadro: com `defer`, no WebKit o script roda antes de uma folha de estilo
pendente ser aplicada, e a primeira medida leria o layout de uma coluna só — os --dx saíam perto de
zero e a seção nascia espalhada no lugar de empilhada. A atualização de renderização, que é quando
o requestAnimationFrame roda, só acontece depois de a folha entrar. */
(function () {
'use strict';
function iniciar() {
var caixas = Array.prototype.slice.call(document.querySelectorAll('.c-espalhar'));
if (!caixas.length || !window.matchMedia) { return; }
var desktop = window.matchMedia('(min-width: 1024px)');
var reduzir = window.matchMedia('(prefers-reduced-motion: reduce)');
var pedido = false;
function suave(u) { return u * u * (3 - 2 * u); }
function medir() {
caixas.forEach(function (caixa) {
var centro = caixa.clientWidth / 2;
Array.prototype.forEach.call(caixa.querySelectorAll('.c-espalhar__item'), function (item) {
item.style.setProperty('--dx', (centro - (item.offsetLeft + item.offsetWidth / 2)).toFixed(1) + 'px');
});
});
}
function aplicar() {
pedido = false;
if (!desktop.matches || reduzir.matches) {
caixas.forEach(function (c) { c.style.removeProperty('--espalha'); });
return;
}
var vh = window.innerHeight;
caixas.forEach(function (caixa) {
var r = caixa.getBoundingClientRect();
var fim = Math.max(80, (vh - r.height) / 2); // o topo da caixa quando ela está no meio da tela
var p = Math.min(1, Math.max(0, (vh - r.top) / (vh - fim)));
caixa.style.setProperty('--espalha', suave(p).toFixed(3));
});
}
function agendar() { if (!pedido) { pedido = true; window.requestAnimationFrame(aplicar); } }
medir();
aplicar();
window.requestAnimationFrame(function () { medir(); agendar(); });
window.addEventListener('scroll', agendar, { passive: true });
window.addEventListener('resize', function () { medir(); agendar(); });
if (desktop.addEventListener) { desktop.addEventListener('change', function () { medir(); agendar(); }); }
if (reduzir.addEventListener) { reduzir.addEventListener('change', agendar); }
}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', iniciar); } else { iniciar(); }
})();
/* ===== markup.html ================================================================= */
<!-- ROL-01 · Espalhamento. Dois ou mais .c-espalhar__item dentro de .c-espalhar.
Abaixo de 1024 px eles empilham na vertical e nada se move: o efeito é só do desktop. -->
<div class="c-espalhar">
<figure class="c-espalhar__item">
<img src="foto.jpg" alt="O espaço montado" style="width:100%;border-radius:inherit;display:block">
</figure>
<div class="c-espalhar__item" style="padding:16px">
<table class="c-tabela">
<tbody>
<tr><th scope="row">Área</th><td>30 m²</td></tr>
<tr><th scope="row">Pé-direito</th><td>4 m</td></tr>
</tbody>
</table>
</div>
</div>
/* ===== 07-stack-spread.tsx ========================================================= */
// Built using Hyperiux Vault: https://vault.hyperiux.com
"use client";
import {
motion,
useScroll,
useTransform,
useReducedMotion,
useMotionValue,
useSpring,
useMotionValueEvent,
type MotionValue,
} from "motion/react";
import { useEffect, useRef, useState } from "react";
const IMG_BASE =
"https://pub-8abee449136941f5b0a1cd2c014534e9.r2.dev/vault-listing-images/assets-images/stack-spread";
const IMG = {
plane: `${IMG_BASE}/img1.png`,
painting: `${IMG_BASE}/img2.png`,
breaker: `${IMG_BASE}/img3.png`,
dog: `${IMG_BASE}/img4.png`,
footballer: `${IMG_BASE}/img5.png`,
jacket: `${IMG_BASE}/img6.png`,
meadow: `${IMG_BASE}/img7.png`,
stripes: `${IMG_BASE}/img8.png`,
} as const;
// per-image rest scale, keyed by img index (1-8). default 1, drop below to shrink.
const SCALE: Partial<Record<number, number>> = {
1: 0.9,
2: 0.8,
3: 0.9,
4: 0.8,
5: 0.8,
6: 0.9,
7: 0.9,
8: 0.7,
};
const s = (i: number) => SCALE[i] ?? 1;
// array order = stack order, back (z 2) -> front (z 9)
const CARDS: StackSpreadCard[] = [
// top-left stripes (img08) — sm row 1 left
{
item: { src: IMG.stripes, alt: "Colour stripes" },
stackOffset: { x: -8, y: -10 },
stackRotate: -18,
target: { x: -20, y: -34, rotate: 0, scale: s(8), w: 17, h: 22 },
targetSm: { x: -22, y: -40 },
z: 2,
},
// top-right meadow (img07) — sm row 1 right
{
item: { src: IMG.meadow, alt: "Wildflower meadow" },
stackOffset: { x: 14, y: -10 },
stackRotate: 20,
target: { x: 32, y: -30, rotate: 0, scale: s(7), w: 18, h: 32 },
targetSm: { x: 22, y: -40 },
z: 3,
},
// mid-left jacket (img06) — sm row 2 left
{
item: { src: IMG.jacket, alt: "Figure in a leather jacket" },
stackOffset: { x: -16, y: 0 },
stackRotate: -4,
target: { x: -36, y: -2, rotate: 0, scale: s(6), w: 15, h: 32 },
targetSm: { x: -22, y: -19 },
z: 4,
},
// top-centre footballer (img05) — sm row 2 right
{
item: { src: IMG.footballer, alt: "Footballer mid-kick" },
stackOffset: { x: 1, y: -10 },
stackRotate: -2,
target: { x: 6, y: -32, rotate: 0, scale: s(5), w: 25, h: 30 },
targetSm: { x: 22, y: -19 },
z: 5,
},
// mid-right dog (img04) — sm row 3 left
{
item: { src: IMG.dog, alt: "Terrier in profile" },
stackOffset: { x: 18, y: 1 },
stackRotate: 6,
target: { x: 37, y: 6, rotate: 0, scale: s(4), w: 18, h: 32 },
targetSm: { x: -22, y: 20 },
z: 6,
},
// bottom-left breaker (img03) — sm row 3 right
{
item: { src: IMG.breaker, alt: "Breakdancer holding a pose" },
stackOffset: { x: -6, y: 10 },
stackRotate: 6,
target: { x: -24, y: 34, rotate: 0, scale: s(3), w: 22, h: 25 },
targetSm: { x: 22, y: 20 },
z: 7,
},
// bottom-centre painting (img02) — sm row 4 left
{
item: { src: IMG.painting, alt: "Renaissance fresco detail" },
stackOffset: { x: 8, y: 7 },
stackRotate: 3,
target: { x: 2, y: 36, rotate: 0, scale: s(2), w: 20, h: 26 },
targetSm: { x: -22, y: 40 },
z: 8,
},
// bottom-right plane (img01) — sm row 4 right
{
item: { src: IMG.plane, alt: "Vintage fighter plane" },
stackOffset: { x: 20, y: 12 },
stackRotate: -7,
target: { x: 30, y: 34, rotate: 0, scale: s(1), w: 16, h: 20 },
targetSm: { x: 22, y: 40 },
z: 9,
},
];
// ---------------------------------------------------------------------------
// Mechanism
// ---------------------------------------------------------------------------
// Scroll progress where the cluster starts scattering and where it finishes.
const SCATTER_START = 0.12;
const SCATTER_END = 0.9;
const PARALLAX_X = 2.6;
const PARALLAX_Y = 2.2;
const PARALLAX_SPRING = { stiffness: 90, damping: 22, mass: 0.6 };
const parallaxDepth = (i: number, total: number) =>
total <= 1 ? 1 : 0.55 + (i / (total - 1)) * 0.75;
const SUB = "Digital products, interfaces, and experiences built around people.";
const RESPONSIVE = {
desktop: {
scale: null as number | null,
small: false,
colX: null as number | null,
card: null as { w: number; h: number } | null,
},
small: {
scale: 0.72,
small: true,
colX: 22,
card: { w: 40, h: 20 },
},
};
function useResponsive() {
const [r, setR] = useState(RESPONSIVE.desktop);
useEffect(() => {
// Touch vs. mouse, not raw width: a narrow but mouse-driven frame (21st
// preview, split editor) keeps the desktop scatter + pointer parallax;
// only real touch devices drop to the stacked column layout.
const mq = window.matchMedia("(pointer: coarse)");
const read = () => setR(mq.matches ? RESPONSIVE.small : RESPONSIVE.desktop);
read();
mq.addEventListener("change", read);
return () => mq.removeEventListener("change", read);
}, []);
return r;
}
function usePointerParallax(active: boolean, enabled: boolean) {
const rawX = useMotionValue(0);
const rawY = useMotionValue(0);
const x = useSpring(rawX, PARALLAX_SPRING);
const y = useSpring(rawY, PARALLAX_SPRING);
useEffect(() => {
if (!enabled) return;
if (!active) {
rawX.set(0);
rawY.set(0);
return;
}
const onMove = (event: PointerEvent) => {
rawX.set((event.clientX / window.innerWidth) * 2 - 1);
rawY.set((event.clientY / window.innerHeight) * 2 - 1);
};
const onLeave = () => {
rawX.set(0);
rawY.set(0);
};
window.addEventListener("pointermove", onMove, { passive: true });
document.addEventListener("pointerleave", onLeave);
return () => {
window.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerleave", onLeave);
};
}, [active, enabled, rawX, rawY]);
return { x, y };
}
export interface StackSpreadItem {
src: string;
alt?: string;
}
export interface StackSpreadTarget {
x: number;
y: number;
rotate: number;
scale?: number;
w: number;
h: number;
}
export interface StackSpreadCard {
item: StackSpreadItem;
target: StackSpreadTarget;
/** final x/y (vw/vh) for tablet + mobile; falls back to `target` */
targetSm?: { x: number; y: number };
/** angle while clustered */
stackRotate?: number;
/** offset while clustered (vw/vh) */
stackOffset?: { x: number; y: number };
/** paint order, higher on top */
z?: number;
}
function Card({
card,
progress,
reduce,
clusterRotation,
scaleMul,
isSmall,
colX,
fixedCard,
stackScale,
cardRadius,
pointer,
depth,
}: {
card: StackSpreadCard;
progress: MotionValue<number>;
reduce: boolean | null;
clusterRotation: boolean;
/** uniform rest-scale for every card; null = use each card's own scale */
scaleMul: number | null;
isSmall: boolean;
colX: number | null;
fixedCard: { w: number; h: number } | null;
/** scale of the cards while clustered, before the scatter */
stackScale: number;
/** corner radius on each card, in px (desktop) */
cardRadius: number;
pointer: { x: MotionValue<number>; y: MotionValue<number> };
depth: number;
}) {
const { item, target } = card;
const flat = reduce === true;
const stackRotate = flat ? 0 : clusterRotation ? card.stackRotate ?? 0 : 0;
const stackOffset = card.stackOffset ?? { x: 0, y: 0 };
const restScale = scaleMul ?? target.scale ?? 1;
// final resting spot: column grid on small screens, scatter on desktop
const sm = isSmall && card.targetSm ? card.targetSm : null;
const endX = sm
? colX != null
? Math.sign(sm.x) * colX
: sm.x
: target.x;
const endY = sm ? sm.y : target.y;
const endRotate = flat || isSmall ? 0 : target.rotate;
// -50% keeps card centred on its anchor
const translate = useTransform(
[progress, pointer.x, pointer.y],
([p, px, py]: number[]) => {
const tx = stackOffset.x + (endX - stackOffset.x) * p;
const ty = stackOffset.y + (endY - stackOffset.y) * p;
const drift = depth * p;
const dx = tx - px * PARALLAX_X * drift;
const dy = ty - py * PARALLAX_Y * drift;
return `calc(-50% + ${dx}vw) calc(-50% + ${dy}vh)`;
},
);
const rotate = useTransform(progress, [0, 1], [stackRotate, endRotate]);
const scale = useTransform(progress, [0, 1], [stackScale, restScale]);
return (
<motion.div
className="absolute left-1/2 top-1/2 will-change-transform"
style={{
width: `${fixedCard ? fixedCard.w : target.w}vw`,
height: `${fixedCard ? fixedCard.h : target.h}vh`,
zIndex: card.z ?? 1,
translate,
rotate,
scale,
}}
>
<CardFace item={item} cardRadius={cardRadius} />
</motion.div>
);
}
function CardFace({
item,
cardRadius,
}: {
item: StackSpreadItem;
cardRadius: number;
}) {
return (
<div
className="relative h-full w-full overflow-hidden max-md:rounded-[4vw]"
style={{ borderRadius: `${cardRadius}px` }}
>
<img
src={item.src}
alt={item.alt ?? ""}
draggable={false}
className="absolute inset-0 h-full w-full object-cover"
/>
</div>
);
}
interface StackSpreadStageProps {
cards: StackSpreadCard[];
/** scatter scroll distance, in vh */
scrollLength?: number;
bgColor?: string;
/** fan the clustered stack (default) or start flat */
clusterRotation?: boolean;
/** scale of the cards while clustered, before the scatter */
stackScale?: number;
/** corner radius on each card, in px (desktop only — mobile keeps its responsive radius) */
cardRadius?: number;
/** color of the centre headline and subtitle */
textColor?: string;
/** scroll progress (0-1) where the centre text starts fading in */
textFadeStart?: number;
/** show the "scroll to spread" hint at the bottom until the scatter begins */
showScrollHint?: boolean;
}
function StackSpreadStage({
cards,
scrollLength = 350,
bgColor = "#ececeb",
clusterRotation = true,
stackScale = 0.82,
cardRadius = 8,
textColor = "#141414",
textFadeStart = 0.3,
showScrollHint = true,
}: StackSpreadStageProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const { scale: scaleMul, small: isSmall, colX, card: fixedCard } =
useResponsive();
const { scrollYProgress } = useScroll({
target: wrapRef,
offset: ["start start", "end end"],
});
// hold, scatter, then settle
const progress = useTransform(
scrollYProgress,
[0, SCATTER_START, SCATTER_END, 1],
[0, 0, 1, 1],
);
// centre text always fades in on scroll; the scale-in is dropped only when
// reduced motion is confirmed (`true`), not on the null SSR value.
const [spread, setSpread] = useState(false);
useMotionValueEvent(progress, "change", (p) => {
setSpread((was) => (was ? p > 0.985 : p >= 0.999));
});
const parallaxEnabled = reduce !== true && !isSmall;
const pointer = usePointerParallax(spread, parallaxEnabled);
const noScale = reduce === true;
const copyOpacity = useTransform(progress, [textFadeStart, textFadeStart + 0.35], [0, 1]);
const copyScale = useTransform(progress, [textFadeStart, 0.9], [0.85, 1]);
// scroll hint: visible while clustered, gone by the time the scatter starts
const hintOpacity = useTransform(progress, [0, SCATTER_START], [1, 0]);
return (
<section
ref={wrapRef}
className="relative w-full"
style={{ height: `${scrollLength}vh`, backgroundColor: bgColor }}
>
<div className="sticky top-0 h-screen w-full overflow-hidden">
{/* centre text */}
<motion.div
className="pointer-events-none absolute inset-0 z-[5] flex flex-col items-center justify-center px-6 text-center max-md:px-8"
style={{
opacity: copyOpacity,
scale: noScale ? 1 : copyScale,
}}
>
<h2
className="w-full whitespace-pre-line text-[4.5vw] font-normal leading-none! tracking-tight max-md:text-[10vw]"
style={{ color: textColor }}
>
Design
<span className="opacity-60"> {" "}
That
{" "}
</span>
Responds.
</h2>
<p
className="mt-[1.2vw] w-full max-w-[42ch] text-[1.15vw] leading-relaxed tracking-tight max-md:mt-3 max-md:text-[3.6vw]"
style={{ color: textColor, opacity: 0.6 }}
>
{SUB}
</p>
</motion.div>
{/* scattering cards */}
<div className="absolute inset-0 z-10">
{cards.map((card, i) => (
<Card
key={i}
card={card}
progress={progress}
reduce={reduce}
clusterRotation={clusterRotation}
scaleMul={scaleMul}
isSmall={isSmall}
colX={colX}
fixedCard={fixedCard}
stackScale={stackScale}
cardRadius={cardRadius}
pointer={pointer}
depth={parallaxEnabled ? parallaxDepth(i, cards.length) : 0}
/>
))}
</div>
{/* scroll hint */}
{showScrollHint && (
<motion.div
className="pointer-events-none absolute inset-x-0 bottom-[3vh] z-20 flex flex-col items-center gap-[0.6vh] text-[0.8vw] font-medium uppercase tracking-[0.2em] max-md:bottom-6 max-md:gap-1 max-md:text-[2.8vw]"
style={{ color: textColor, opacity: hintOpacity }}
>
<span>Scroll</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="animate-bounce max-md:h-[4vw] max-md:w-[4vw]"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</motion.div>
)}
</div>
</section>
);
}
export interface StackSpreadProps {
/** scatter scroll distance, in vh */
scrollLength?: number;
bgColor?: string;
/** fan the clustered stack (default) or start flat */
clusterRotation?: boolean;
/** scale of the cards while clustered, before the scatter */
stackScale?: number;
/** corner radius on each card, in px (desktop only — mobile keeps its responsive radius) */
cardRadius?: number;
/** color of the centre headline and subtitle */
textColor?: string;
/** scroll progress (0-1) where the centre text starts fading in */
textFadeStart?: number;
/** show the "scroll to spread" hint at the bottom until the scatter begins */
showScrollHint?: boolean;
}
export default function StackSpread({
scrollLength = 350,
bgColor = "#ececeb",
clusterRotation = true,
stackScale = 0.82,
cardRadius = 8,
textColor = "#141414",
textFadeStart = 0.3,
showScrollHint = true,
}: StackSpreadProps) {
return (
<StackSpreadStage
cards={CARDS}
scrollLength={scrollLength}
bgColor={bgColor}
clusterRotation={clusterRotation}
stackScale={stackScale}
cardRadius={cardRadius}
textColor={textColor}
textFadeStart={textFadeStart}
showScrollHint={showScrollHint}
/>
);
}
Já rodou em
- trento-pedro · home v3 — seção 3, a referência do espalhamento card ↔ tabela (leva 1, 16/09/2026)
Exige
- react
- motion/react (useScroll, useTransform, useSpring, useMotionValue, useReducedMotion, useMotionValueEvent)
- Tailwind
Como se configura
--c-rol-altura-palco--c-rol-card-fundo--c-rol-card-linha--c-rol-raio
A ficha
O que o 21st.dev entregou
Recebido em 16/09/2026 (noite), na primeira leva de correções depois de tablet e desktop, como referência para a seção 3: "a tela dessa seção inicie com o card de imagens e a tabela sobrepostos e, conforme scrollamos, o card vai para a esquerda e a tabela para a direita".
Dependências: React, motion/react (useScroll, useTransform, useSpring, useMotionValue, useReducedMotion, useMotionValueEvent), Tailwind. Imagens de demonstração num bucket R2 do Hyperiux.
Como funciona:
- Uma seção de 350 vh com um palco
stickyde 100 vh dentro; o progresso da rolagem (0–1) vira o progresso do espalhamento com um patamar no começo (12 %) e outro no fim (10 %): os cards ficam empilhados, se espalham e assentam. - 8 cards ancorados no centro (
translate: calc(-50% + x vw) calc(-50% + y vh)), cada um com um deslocamento e uma rotação na pilha (stackOffset,stackRotate) e um alvo (target: posição em vw/vh, escala, tamanho). A pilha começa a 82 % de escala (stackScale). - Texto central que aparece em fade + escala entre 30 % e 90 % do progresso; dica "Scroll" que some quando o espalhamento começa.
- Paralaxe do cursor só depois de espalhado e só com mouse (
pointer: coarsedesliga): cada card deriva com uma profundidade própria, por mola. - Toque (
pointer: coarse): os cards vão para uma grade de duas colunas (targetSm,colX) em vez do espalhamento. prefers-reduced-motion: sem rotação na pilha e sem escala no texto.
O que serve à Trento, e o que muda na reescrita (rodada de correções 1):
- O mecanismo — sobreposto no centro → cada um na sua coluna, dirigido pela rolagem — é o que o Rafa pediu. Aqui são dois elementos (o carrossel de fotos e a tabela), não oito, e o destino é a grade de duas colunas que já existe no desktop, não posições em vw/vh.
- Sem pin e sem rolagem extra: a referência prende a tela por 350 vh; na Trento o espalhamento acompanha a subida natural do bloco (da base da tela até o meio), sem sequestrar a rolagem nem alongar a página. É a decisão marcada com ⚖️ no §13 do dossiê — reversível para o pin se o Rafa preferir.
- Só no desktop (≥ 1024): no tablet e no celular o carrossel e a tabela ficam empilhados de verdade, sem animação.
- Sem paralaxe de cursor, sem texto central, sem dica "Scroll": não há o que dizer no meio do espalhamento.
motion/reactsai:espalhar.js(umscroll+requestAnimationFrame) escreve--espalhano bloco e o CSS faz o resto comcalc(); sem JS ou com "reduzir movimento", o layout final aparece direto.
O que a adaptação da OCTA mudou
prefers-reduced-motionentrega o estado final espalhado, sem a prisão de rolagem
Lacunas do original
- As imagens de demonstração vivem num bucket R2 do Hyperiux — some se o bucket sair do ar
- Seção de 350 vh: come três telas de rolagem para um efeito só
Acessibilidade não apurada no original além do useReducedMotion, que ele respeita.
⚠️ Lista incompleta: este componente não passou por apuração de acessibilidade. O que está acima é o que se observou de passagem, não um laudo.