Pular para o conteúdo
Biblioteca ROL-02

Mídia que expande na rolagem

Hero com vídeo ou imagem num cartão que cresce até quase a tela inteira conforme a rolagem; o título se parte e sai pelas laterais, e o conteúdo abaixo só aparece quando a mídia termina de abrir.

usa o ROL-02
abrir sozinho ↗
Código
ROL-02 · alias midia-expande
Função
Rolagem e seção presa
Fonte
21st.dev · scroll-expansion-hero.tsx
Recebido
06/10/2026
Status
acervo
Licença
não verificada

O código

expandir.css + expandir.js + markup.html 235 linhas · 3 arquivo(s) numa caixa só
/* ===== expandir.css ================================================================ */

/* ROL-02 · Mídia que expande na rolagem — reescrita do scroll-expansion-hero do 21st.dev sem React,
   Next nem Framer Motion. Um cartão de mídia cresce até quase a tela inteira enquanto a seção é
   rolada; o título se parte em dois e sai pelas laterais, o fundo some e o conteúdo abaixo aparece.

   O que mudou em relação ao original, e por quê:
     - **a rolagem é a da página, não sequestrada.** O original escuta wheel/touchmove no window,
       chama preventDefault e força scrollTo(0, 0) até a mídia abrir: teclado, barra de rolagem e
       leitor de tela ficam presos no topo, e ele só funciona como primeira seção. Aqui um trilho
       alto com palco sticky converte a rolagem nativa em progresso — qualquer meio de rolar serve,
       em qualquer posição da página.
     - **o curso é o do original.** Lá, 1 px de roda vale 0,0009 de progresso: ~1100 px para abrir.
       O trilho padrão é exatamente isso a mais que a tela.
     - **um título, não dois.** O original tem dois <h2> para as duas metades; aqui é um <h2> com
       duas partes, que o leitor de tela lê como uma frase.
     - **cor em token**, não `text-blue-200` cravado.
     - **sem iframe do YouTube embutido.** A mídia é o que você puser dentro de __midia (img, video
       ou iframe) — o componente não monta URL de terceiro.

   `--expande` vai de 0 (cartão pequeno) a 1 (aberto). **O padrão é 1**: sem JS ou com "reduzir
   movimento", o trilho some e a mídia aparece aberta com o título por cima — a falha mostra. */
.c-expandir {
  --c-expandir-trilho: calc(100vh + 1100px);   /* altura total rolável da seção */
  --c-expandir-larg-ini: 300px;
  --c-expandir-larg-ganho: 1250px;
  --c-expandir-alt-ini: 400px;
  --c-expandir-alt-ganho: 400px;
  --c-expandir-afasta: 150vw;                  /* quanto cada metade do título anda até sair */
  --c-expandir-raio: 16px;
  --c-expandir-texto: #ebebeb;
  --c-expandir-veu: #000;
  --c-expandir-sombra: 0 0 50px rgba(0, 0, 0, .3);

  --expande: 1;
  position: relative;
  color: var(--c-expandir-texto);
}
@media (max-width: 767.98px) {
  .c-expandir {
    --c-expandir-larg-ganho: 650px;
    --c-expandir-alt-ganho: 200px;
    --c-expandir-afasta: 180vw;
  }
}

.c-expandir__palco {
  position: relative;
  height: 100vh;
  height: 100dvh;
  overflow: hidden;            /* as metades do título saem da tela; sem isto a página ganha rolagem lateral */
  display: grid;
  place-items: center;
}
.c-expandir.is-vivo .c-expandir__trilho { height: var(--c-expandir-trilho); }
.c-expandir.is-vivo .c-expandir__palco { position: sticky; top: 0; }

.c-expandir__fundo {
  position: absolute;
  inset: 0;
  opacity: calc(1 - var(--expande));
}
.c-expandir__fundo > img,
.c-expandir__fundo > video { display: block; width: 100%; height: 100%; object-fit: cover; }
.c-expandir__fundo::after { content: ""; position: absolute; inset: 0; background: rgba(0, 0, 0, .1); }

.c-expandir__midia {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: min(95vw, calc(var(--c-expandir-larg-ini) + var(--c-expandir-larg-ganho) * var(--expande)));
  height: min(85vh, calc(var(--c-expandir-alt-ini) + var(--c-expandir-alt-ganho) * var(--expande)));
  border-radius: var(--c-expandir-raio);
  box-shadow: var(--c-expandir-sombra);
}
.c-expandir__midia > img,
.c-expandir__midia > video,
.c-expandir__midia > iframe {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border: 0;
  border-radius: inherit;
}
.c-expandir__veu {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--c-expandir-veu);
  opacity: calc(.5 - .3 * var(--expande));
  pointer-events: none;
}
.c-expandir__legenda {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin-top: 16px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  text-align: center;
}
.c-expandir__data { margin: 0; font-size: 1.5rem; }
.c-expandir__dica { margin: 0; }

.c-expandir__titulo {
  position: relative;
  z-index: 1;
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  font-size: clamp(2.25rem, 1.4rem + 2.6vw, 3.75rem);
  font-weight: 700;
  line-height: 1.1;
  text-align: center;
}
.c-expandir__parte { display: block; }
.c-expandir[data-mistura="diferenca"] .c-expandir__titulo { mix-blend-mode: difference; }

.c-expandir.is-vivo .c-expandir__parte--a,
.c-expandir.is-vivo .c-expandir__data { transform: translateX(calc(-1 * var(--c-expandir-afasta) * var(--expande))); }
.c-expandir.is-vivo .c-expandir__parte--b,
.c-expandir.is-vivo .c-expandir__dica { transform: translateX(calc(var(--c-expandir-afasta) * var(--expande))); }

.c-expandir__conteudo { padding: 40px 32px; }
@media (min-width: 768px) { .c-expandir__conteudo { padding: 80px 64px; } }
.c-expandir.is-vivo .c-expandir__conteudo { opacity: 0; }
/* a transição só arma depois do primeiro quadro: armada junto com .is-vivo, o conteúdo
   faria um fade de saída no carregamento */
.c-expandir.is-armado .c-expandir__conteudo { transition: opacity .7s ease; }
.c-expandir.is-vivo.is-aberto .c-expandir__conteudo { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .c-expandir { --expande: 1 !important; }
  .c-expandir .c-expandir__trilho { height: auto !important; }
  .c-expandir .c-expandir__palco { position: relative !important; }
  .c-expandir .c-expandir__parte,
  .c-expandir .c-expandir__data,
  .c-expandir .c-expandir__dica { transform: none !important; }
  .c-expandir .c-expandir__conteudo { opacity: 1 !important; transition: none; }
}


/* ===== expandir.js ================================================================= */

/* ROL-02 · Mídia que expande na rolagem. Liga a seção (.is-vivo, que dá altura ao trilho e prende o
   palco) e escreve --expande de 0 a 1 conforme o trilho passa pela tela. Marca .is-aberto quando a
   mídia termina de abrir e só desmarca abaixo de 0,75 — a mesma folga do original, para o conteúdo
   não piscar na fronteira.

   Com "reduzir movimento" a seção não liga: o CSS mostra a mídia aberta, e o vídeo em autoplay
   é pausado. .is-armado liga a transição do conteúdo só depois do primeiro quadro.

   Mede DE NOVO no primeiro quadro, pelo mesmo motivo do ROL-01: com folha de estilo pendente o
   WebKit roda o script antes do layout final, e a primeira leitura do trilho sairia errada. */
(function () {
  'use strict';

  function iniciar() {
    var raizes = Array.prototype.slice.call(document.querySelectorAll('.c-expandir'));
    if (!raizes.length || !window.matchMedia) { return; }
    var reduzir = window.matchMedia('(prefers-reduced-motion: reduce)');
    var pedido = false;

    function aplicar() {
      pedido = false;
      var vh = window.innerHeight;
      raizes.forEach(function (raiz) {
        if (reduzir.matches) {
          raiz.classList.remove('is-vivo', 'is-aberto', 'is-armado');
          raiz.style.removeProperty('--expande');
          Array.prototype.forEach.call(raiz.querySelectorAll('video[autoplay]'), function (v) { v.pause(); });
          return;
        }
        raiz.classList.add('is-vivo');
        var trilho = raiz.querySelector('.c-expandir__trilho');
        if (!trilho) { return; }
        var r = trilho.getBoundingClientRect();
        var curso = r.height - vh;
        var p = curso > 0 ? Math.min(1, Math.max(0, -r.top / curso)) : 1;
        raiz.style.setProperty('--expande', p.toFixed(4));
        if (p >= 1) { raiz.classList.add('is-aberto'); } else if (p < 0.75) { raiz.classList.remove('is-aberto'); }
      });
    }

    function agendar() { if (!pedido) { pedido = true; window.requestAnimationFrame(aplicar); } }

    aplicar();
    window.requestAnimationFrame(function () {
      agendar();
      raizes.forEach(function (raiz) { if (!reduzir.matches) { raiz.classList.add('is-armado'); } });
    });
    window.addEventListener('scroll', agendar, { passive: true });
    window.addEventListener('resize', agendar);
    if (reduzir.addEventListener) { reduzir.addEventListener('change', agendar); }
  }

  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', iniciar); } else { iniciar(); }
})();


/* ===== markup.html ================================================================= */

<!-- ROL-02 · Mídia que expande na rolagem. Funciona em qualquer posição da página: o trilho prende
     o palco enquanto a mídia abre, e depois a página segue. O fundo é decorativo (alt vazio).
     data-mistura="diferenca" liga o mix-blend-mode do título (o textBlend do original). -->
<section class="c-expandir">
  <div class="c-expandir__trilho">
    <div class="c-expandir__palco">
      <div class="c-expandir__fundo"><img src="fundo.jpg" alt=""></div>
      <div class="c-expandir__midia">
        <video src="video.mp4" poster="poster.jpg" autoplay muted loop playsinline preload="auto"
               disablepictureinpicture disableremoteplayback aria-hidden="true"></video>
        <span class="c-expandir__veu"></span>
        <div class="c-expandir__legenda">
          <p class="c-expandir__data">Outubro de 2026</p>
          <p class="c-expandir__dica">Role para abrir</p>
        </div>
      </div>
      <h2 class="c-expandir__titulo">
        <span class="c-expandir__parte c-expandir__parte--a">Estúdio</span>
        <span class="c-expandir__parte c-expandir__parte--b">por dentro</span>
      </h2>
    </div>
  </div>
  <div class="c-expandir__conteudo">
    <p>O que vem depois da mídia aberta.</p>
  </div>
</section>

Já rodou em

Ainda não rodou em peça publicada.

Exige

  • react (useEffect, useRef, useState)
  • next/image — não roda fora do Next
  • framer-motion (motion.div, motion.h2, motion.section)
  • Tailwind

Como se configura

  • --c-expandir-trilho
  • --c-expandir-larg-ini
  • --c-expandir-larg-ganho
  • --c-expandir-alt-ini
  • --c-expandir-alt-ganho
  • --c-expandir-afasta
  • --c-expandir-raio
  • --c-expandir-texto
  • --c-expandir-veu
  • --c-expandir-sombra

A ficha

O que o 21st.dev entregou

Um arquivo só, ScrollExpandMedia, sem demo. Nome do arquivo e autor não vieram com o código: o nome scroll-expansion-hero.tsx é o desta ficha, não o do 21st.

Dependências _(observado nos import)_: React (useEffect, useRef, useState), next/image (só roda dentro do Next), framer-motion (motion.div, motion.h2, motion.section) e Tailwind.

Props _(observado)_: mediaType ("video" | "image", padrão vídeo), mediaSrc, posterSrc, bgImageSrc, title, date, scrollToExpand (o texto da dica), textBlend (liga mix-blend-difference no título) e children (o conteúdo que aparece depois).

Como funciona _(observado no código)_:

  • O progresso (0–1) não vem da rolagem da página: vem da roda e do toque, capturados no window com preventDefault. A roda soma deltaY × 0,0009 (≈ 1100 px de roda para abrir). No toque, 0,005 para descer e 0,008 para voltar, mais sensível na volta, de propósito.
  • Enquanto a mídia não abre, um ouvinte de scroll força window.scrollTo(0, 0): a página fica presa no topo. Aberta, ela solta, e a rolagem para cima no topo (scrollY ≤ 5) a fecha de novo.
  • Cartão de mídia: de 300 × 400 px a 1550 × 800 no desktop e 950 × 600 abaixo de 768 px, limitado a 95vw × 85vh. Raio rounded-2xl (16 px) por fora, rounded-xl (12 px) na mídia, sombra 0 0 50px rgba(0,0,0,.3).
  • Fundo (bgImageSrc) em tela cheia, com véu preto a 10 %, some com opacity: 1 − progresso.
  • Véu na mídia: preto a 30 % multiplicado por 0,5 − 0,3·p no vídeo e 0,7 − 0,3·p na imagem.
  • Título: a primeira palavra num <h2> e o resto noutro, que andam p × 150vw (180vw no celular) em sentidos opostos até sair da tela. A data e a dica, abaixo do cartão, fazem o mesmo.
  • Conteúdo (children) entra em fade de 0,7 s quando p chega a 1 e sai abaixo de 0,75.
  • Vídeo: autoPlay muted loop playsInline, sem controles, pointer-events: none. Se a URL tiver youtube.com, monta um <iframe> de embed concatenando parâmetros na URL.
  • Cor do texto text-blue-200 cravada. Título 36/48/60 px, peso 700.

Visual em repouso _(inferido do código, não visto rodando no original)_: um cartão retrato pequeno no centro da tela, sobre a imagem de fundo, com o título em duas linhas por cima.


O que a adaptação da OCTA mudou

  • A rolagem é a da página. Um trilho de 100vh + 1100px (o mesmo curso do original) com o palco sticky dentro: o JS só lê a posição do trilho e escreve --expande. Setas, espaço, PageDown, barra de rolagem e leitor de tela movem o efeito, e a seção funciona em qualquer posição da página, não só como primeira dobra.
  • prefers-reduced-motion: sem trilho, mídia aberta direto, e o vídeo em autoplay é pausado.
  • A falha mostra: sem JS, o --expande padrão é 1 e o conteúdo fica visível.
  • Um <h2> com duas partes (__parte--a, __parte--b) no lugar de dois.
  • Cor e geometria em token (--c-expandir-*). O textBlend virou data-mistura="diferenca".
  • Sem montagem de URL do YouTube. __midia aceita img, video ou iframe que você puser.
  • O fundo tem alt="", porque é decorativo; o vídeo de exemplo leva aria-hidden.
  • Custo que ficou: largura e altura do cartão mudam a cada quadro (é layout, não transform), como no original. Com um elemento só, não pesou no teste; não foi medido em aparelho fraco.

Lacunas do original

  • Sequestra a rolagem do window inteiro. Teclado e barra de rolagem não abrem a mídia, e a página fica presa no topo até a roda ou o dedo abrirem. Falha de acessibilidade grave, e o componente só funciona como primeira seção.
  • Não respeita prefers-reduced-motion.
  • Dois <h2> para um título só. alt fixo em inglês ("Background", "Media content"), e o fundo decorativo é anunciado.
  • URL do YouTube montada por concatenação: split("v=")[1] leva junto qualquer parâmetro que venha depois do id, e o playlist= sai errado.
  • O useEffect dos ouvintes depende de scrollProgress: os cinco ouvintes são removidos e recriados a cada passo da animação.
  • text-blue-200 cravado; nenhuma cor em token.
  • Sem demo: o 21st entregou só o componente.

Acessibilidade não apurada a fundo; o que está acima foi lido no código, não medido.

⚠️ 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.