Pular para o conteúdo
Biblioteca ROL-01

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
abrir sozinho ↗
Código
ROL-01 · alias stack-spread
Função
Rolagem e seção presa
Fonte
21st.dev · stack spread (Hyperiux Vault)
Recebido
16/09/2026
Status
adotado
Licença
não verificada

O código

espalhar.css + espalhar.js + markup.html 132 linhas · 3 arquivo(s) numa caixa só
/* ===== 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>

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 sticky de 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: coarse desliga): 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/react sai: espalhar.js (um scroll + requestAnimationFrame) escreve --espalha no bloco e o CSS faz o resto com calc(); sem JS ou com "reduzir movimento", o layout final aparece direto.

O que a adaptação da OCTA mudou

  • prefers-reduced-motion entrega 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.