← Biblioteca componentes/hero/video-fundo/ficha.md CódigoTela cheia
hero/video-fundo

Hero com vídeo de fundo em loop

🟢 estavel complexidade média prefixo vfd- hero above-the-fold video loop autoplay poster playsinline performance movimento-reduzido economia-de-dados acessibilidade institucional

Incluir · as linhas que se colam num site novo, depois do core (CLAUDE.md §10)

<link rel="stylesheet" href="componentes/hero/video-fundo/estilo.css">
<script src="componentes/hero/video-fundo/script.js"></script>

markup.html · 154 linhas · 7,8 KB

<!-- ==========================================================================
     HERO — vídeo em loop como superfície da primeira dobra
     Prefixo: vfd-
     Requer: core (tokens.css, base.css, motion.css, motion.js)
             + este estilo.css e este script.js
             + 1 vídeo curto em loop e o POSTER dele (ver ficha, "assets")

     A IDEIA. Um loop de dez segundos sangrado até a borda, escurecido por
     dois gradientes cruzados e por um véu desfocado com máscara em degradê,
     e o texto ancorado embaixo — dentro da mídia, nunca abaixo dela. É a
     mesma anatomia do irmão hero/full-bleed-media, e a troca da foto pelo
     vídeo não é de desenho: é a decisão de performance mais cara que uma
     home toma. Quem não tem o vídeo certo em mãos usa o irmão, que continua
     sendo a escolha certa na maioria dos casos.

     OS QUATRO ATRIBUTOS DO <video> ANDAM JUNTOS: muted, playsinline,
     autoplay e loop. Faltando `muted`, nenhum navegador toca sem gesto;
     faltando `playsinline`, o iPhone abre o vídeo em TELA CHEIA por cima do
     site. Não são quatro preferências, são um conjunto.

     O POSTER NÃO É ENFEITE. É o que a pessoa vê enquanto o vídeo carrega, e
     é o que ela vê PARA SEMPRE em três casos reais: movimento reduzido
     ligado no sistema, economia de dados ligada no celular, e política de
     autoplay que recusou. Por isso ele tem de ser um quadro do próprio
     vídeo e a headline tem de ser legível sobre ele.

     preload="metadata" É ESCOLHA, e as outras duas não servem: `auto` põe
     megabytes na frente do poster, que é o LCP da página; `none` é ignorado
     na presença de `autoplay`, e escrevê-lo documentaria uma intenção que o
     navegador descarta.

     O <video> NÃO TRAZ <source> AQUI. A biblioteca não versiona vídeo em
     showroom/demo/, e o §11 do contrato não deixa asset atravessar. O que
     está no ar neste preview é o POSTER, parado — que é exatamente o
     fallback que o componente promete. No uso do consumidor, descomente os
     dois <source> e aponte para os arquivos. Precedente do mesmo recurso:
     midia/showreel-expansivel.

     O BOTÃO DE PAUSA NASCE [hidden] e quem o mostra é o LIB.pausavel do
     core: sem JavaScript ele seria um controle morto. É o único jeito de a
     pessoa parar o loop, e a WCAG 2.2.2 o exige de qualquer movimento
     automático que passe de cinco segundos. Sob movimento reduzido ele
     continua lá e continua funcionando — o contrato desliga o que anda
     sozinho, nunca o que a pessoa pediu, e por isso aqui o LIB.pausavel é
     chamado SEMPRE (ao contrário do hero/slideshow-cinematico, que sob
     reduce não tem troca automática nenhuma para pausar).

     O RÓTULO DO BOTÃO MORA EM data-rot-pausar / data-rot-tocar, e não no
     JS: é copy, e copy mora no markup. Os dois nomes são os que o core lê,
     e é ele quem escreve o valor no aria-label conforme o estado.

     A RÉGUA DE NÚMEROS É OPCIONAL: apague o .vfd-regua inteiro e o hero
     continua íntegro. Três itens é a conta — quatro empurram o pé para
     fora da dobra em notebook, porque aqui a régua divide a linha com o
     botão de pausa.
     ========================================================================== -->

<section class="vfd sec-dark" data-vfd>

  <!-- A mídia SAI do .wrap para sangrar até a borda (§4): nada de margem
       negativa nem 100vw, que inclui a barra de rolagem. -->
  <div class="vfd-media">
    <video class="vfd-video" data-vfd-video
           muted playsinline autoplay loop
           preload="metadata"
           poster="../../../showroom/demo/ph-wide-3.webp"
           aria-hidden="true">
      <!-- <source src="/midia/hero-fundo.webm" type="video/webm"> -->
      <!-- <source src="/midia/hero-fundo.mp4" type="video/mp4"> -->
    </video>
    <div class="vfd-scrim"></div>
    <div class="grain"></div>
    <div class="vfd-fade"></div>
  </div>

  <!-- O véu é irmão da mídia, e não filho do bloco de texto: precisa
       sangrar até a borda igual ao vídeo. Véu que parasse na coluna
       desenharia duas linhas verticais duras no meio da imagem. -->
  <div class="vfd-veu" aria-hidden="true"></div>

  <div class="wrap vfd-body">
    <div class="vfd-copy">
      <span class="eyebrow">Assessoria independente</span>

      <!-- Cada linha é uma .vfd-ln com um <span> dentro: a máscara é o
           overflow da .vfd-ln, e quem sobe é o span. Duas linhas, com a
           quebra escrita à mão — a terceira desmonta a cascata. -->
      <h1 class="vfd-title">
        <span class="vfd-ln"><span>A independência que</span></span>
        <span class="vfd-ln"><span>seu <em>patrimônio</em> exige.</span></span>
      </h1>

      <p class="vfd-desc">
        Assessoria de investimentos independente para famílias e empresários.
        Diagnóstico, política de investimentos escrita e um comitê que a revisa —
        sem produto próprio para distribuir e com honorários previamente acordados.
      </p>

      <div class="vfd-cta">
        <a href="#contato" class="btn btn-primary">
          Agende um diagnóstico
          <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 7H12M8 3L12 7L8 11"/></svg>
        </a>
        <a href="#quem-somos" class="btn btn-ghost">Conheça a casa</a>
      </div>
    </div>

    <div class="vfd-pe">
      <!-- NÚMEROS ILUSTRATIVOS. Trocar pelos dados reais do cliente antes de
           publicar. Nenhum deles fala de rentabilidade, e é de propósito: a
           régua responde "por que confiar" — modelo de remuneração, praça,
           governança —, nunca "quanto rende". -->
      <div class="vfd-regua">
        <div class="vfd-item">
          <div class="vfd-k">100%</div>
          <div class="vfd-v">Honorários acordados,<br>sem comissão de produto</div>
        </div>
        <div class="vfd-item">
          <div class="vfd-k">SP · POA</div>
          <div class="vfd-v">Escritórios em São Paulo<br>e Porto Alegre</div>
        </div>
        <div class="vfd-item">
          <div class="vfd-k">Comitê</div>
          <div class="vfd-v">Política de investimentos<br>revisada a cada trimestre</div>
        </div>
      </div>

      <!-- DUAS PRIMITIVAS DO CORE, e nenhuma classe local além do parafuso de
           tamanho. .btn-disc é o disco de contorno com ícone e sem texto — ele
           funciona aqui, sobre mídia, porque o botão mora DENTRO do véu
           desfocado; sem o véu seria o caso do .stage-arrow do
           hero/duas-colunas-produto, disco sólido. .btn-play são os dois
           desenhos dentro do disco, em que só um aparece por vez.

           QUEM TROCA O ÍCONE É [data-pausado] NO PRÓPRIO BOTÃO, escrito pelo
           LIB.pausavel. Antes era uma classe na raiz (.vfd.is-tocando), que
           custava ao componente um estado global só para desenhar um ícone.
           Os dois desenhos continuam vivendo no HTML: trocar o `d` de um
           <path> por JS faria a copy de um ícone morar no script. -->
      <button class="btn-disc btn-play vfd-toggle" type="button" data-vfd-toggle hidden
              data-rot-pausar="Pausar o vídeo de fundo"
              data-rot-tocar="Retomar o vídeo de fundo"
              aria-label="Pausar o vídeo de fundo">
        <svg class="btn-play-pausa" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
          <path d="M9 5v14M15 5v14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
        </svg>
        <svg class="btn-play-tocar" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
          <path d="M8 5.5v13l11-6.5z" fill="currentColor"/>
        </svg>
      </button>
    </div>
  </div>

</section>

estilo.css · 313 linhas · 14,9 KB

/* ==========================================================================
   HERO — vídeo em loop como superfície · prefixo vfd-

   Tokens usados:
     --dark --dark-rgb   fundo da dobra, scrim, véu e emenda
     --fg --fg-2         tinta da headline e do apoio
     --fg-muted          rótulo da régua
     --rule              filete da régua
     --accent            realce em itálico e chave da régua
     --display           face da headline e das chaves
     --fs-body           corpo do apoio
     --nav-h             folga do menu fixo no topo da dobra
     --ease              curva única do projeto

   Parafusos (custom properties do próprio componente, §3):
     --vfd-alt           altura da dobra
     --vfd-video-op      opacidade do vídeo sob o scrim — o parafuso do
                         CONTRASTE, ver a nota abaixo
     --vfd-veu-alt       altura do véu desfocado
     --vfd-veu-blur      raio do desfoque do véu — o ÚNICO custo por quadro
                         desta peça. Zerar mantém o degradê e devolve a GPU.

   POR QUE --vfd-video-op É .46 NO DESKTOP E .38 NO CELULAR, e não um número
   redondo: os dois saíram de medição de PIXEL, não de gosto. O que se mediu
   foi a razão de contraste WCAG de cada texto contra o quadro renderizado,
   no pior pixel de cada caixa, e o cenário de aferição é um quadro de vídeo
   BRANCO CHAPADO — nenhum vídeo real é mais claro que isso, então o número
   medido ali é o PISO do contraste da peça, não a média dela.

     opacidade      1440x900: título / apoio        390x844: título / apoio
       .52              4,69   /  3,99                  —
       .46              5,47   /  4,60                5,28  /  3,76
       .38                —                           6,54  /  4,61

   O alvo é 4,5:1 em todos os quatro textos, que é o mínimo da WCAG AA para
   texto normal — o título ainda é texto grande e só precisaria de 3:1, mas
   passar o apoio já obriga a chegar lá. Com o poster real desta demo, os
   mesmos textos medem 10,1 / 7,4 / 11,8 / 5,9. Ou seja: a peça é AA contra
   QUALQUER vídeo, e não só contra um vídeo bem-comportado.

   O celular precisa de mais escuro porque a copy ocupa uma fração muito
   maior da dobra: o apoio sobe até a faixa mais clara do scrim, onde no
   desktop só há imagem. Quem tiver um vídeo escuro de verdade pode subir o
   parafuso — a tabela acima é para a decisão ser informada, não para o
   número ser sagrado.

   A REGRA QUE ORGANIZA O ARQUIVO: nenhum estado de repouso depende só do
   script. Cada bloco que nasce invisível tem, colado nele, o
   @media(scripting:none) que o devolve pronto e o
   @media(prefers-reduced-motion) que o entrega parado. Sem os dois, uma
   página sem JavaScript mostraria vídeo e nada mais — que é o defeito exato
   que o core/motion.css trata para os ganchos DELE, e que nenhum componente
   herda de graça quando o portão é uma classe própria.

   NÃO HÁ [data-rv] AQUI, e não é esquecimento. Hero está acima da linha de
   corte: já intersecta na carga, e o que se quer é uma CASCATA em ordem
   escrita (linha, linha, apoio, ações, régua), não cinco reveals disparando
   juntos. Quem abre o portão é a classe .in que o script põe na raiz.

   NÃO HÁ PARALLAX, ao contrário do irmão hero/full-bleed-media. Lá o
   deslocamento existe justamente porque a foto está parada. Aqui a mídia já
   é movimento; somar um segundo eixo faria a dobra inteira nadar.
   ========================================================================== */

.vfd{
  --vfd-alt:100vh;
  --vfd-video-op:.46;
  --vfd-veu-alt:56%;
  --vfd-veu-blur:10px;

  position:relative;overflow:hidden;
  background:var(--dark);
  min-height:var(--vfd-alt);
}
/* svh desconta a barra do navegador no celular, onde vh a ignora e o pé da
   dobra fica embaixo dela. Em @supports porque a peça tem de abrir inteira
   em base antiga, e ali vh é o que existe. */
@supports (height:100svh){.vfd{--vfd-alt:100svh}}

/* ---- mídia ----------------------------------------------------------------
   z-index:0 de propósito: cria o contexto de empilhamento que prende o
   .grain (que traz z-index:1 do core) aqui dentro, e deixa véu e corpo
   ordenados sem ninguém adivinhar número. */
.vfd-media{position:absolute;inset:0;z-index:0;overflow:hidden}

/* object-fit vale para o vídeo E para o poster. Sem ele o poster de 16:9
   entra com tarja dos dois lados em tela mais alta que larga — e o poster é
   o que sustenta a dobra em três casos reais: movimento reduzido, economia
   de dados e autoplay recusado. */
.vfd-video{
  position:absolute;inset:0;
  width:100%;height:100%;
  object-fit:cover;object-position:50% 46%;
  opacity:var(--vfd-video-op);
}

/* Dois gradientes cruzados. O VERTICAL é o principal, e essa é a diferença
   de forma para o irmão de foto: lá o texto mora à esquerda e o scrim é
   horizontal a 100°; aqui o texto atravessa a coluna inteira ancorado
   embaixo, e o que precisa escurecer é o pé — mais o topo, por causa do
   menu fixo. O horizontal entra fraco, só para o canto direito não brilhar
   sozinho em tela larga. */
.vfd-scrim{
  position:absolute;inset:0;
  background:
    linear-gradient(to bottom,
      rgba(var(--dark-rgb),.60) 0%,
      rgba(var(--dark-rgb),.18) 26%,
      rgba(var(--dark-rgb),.30) 54%,
      rgba(var(--dark-rgb),.62) 100%),
    linear-gradient(92deg,
      rgba(var(--dark-rgb),.40) 0%,
      rgba(var(--dark-rgb),.08) 54%,
      rgba(var(--dark-rgb),.26) 100%);
}

/* Dissolve a dobra no fundo da página. Sem isto o vídeo termina em corte
   reto contra a seção seguinte, e a emenda fica visível em tela larga. */
.vfd-fade{
  position:absolute;left:0;right:0;bottom:0;height:30%;
  background:linear-gradient(to bottom,
    rgba(var(--dark-rgb),0) 0%,
    rgba(var(--dark-rgb),.34) 46%,
    rgba(var(--dark-rgb),.84) 80%,
    rgb(var(--dark-rgb)) 100%);
}

/* ---- véu desfocado -------------------------------------------------------
   A regra de forma do §6: backdrop-filter SEMPRE com máscara em degradê.
   Caixa reta desenharia uma linha horizontal dura no meio do vídeo e leria
   "chapado".

   POR QUE O VÍDEO PEDE ISTO E A FOTO NÃO: com foto o autor escolhe o quadro
   e calibra o scrim contra ele. Com vídeo todo quadro é outro — a headline
   tem de sobreviver ao clarão que passa no segundo sete. Gradiente escurece
   a média; o desfoque apaga o DETALHE, que é o que come a leitura de um
   texto claro sobre imagem em movimento.

   É também o único custo por quadro da peça: o desfoque é recalculado
   enquanto a imagem por baixo muda. Em máquina fraca, --vfd-veu-blur:0
   mantém o degradê e devolve a GPU. */
.vfd-veu{
  position:absolute;left:0;right:0;bottom:0;z-index:1;
  height:var(--vfd-veu-alt);pointer-events:none;
  background:linear-gradient(to top,
    rgba(var(--dark-rgb),.62) 0%,
    rgba(var(--dark-rgb),.36) 50%,
    rgba(var(--dark-rgb),0) 100%);
  -webkit-mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 46%,transparent 100%);
  mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,.9) 46%,transparent 100%);
}
/* Mesma cautela do .glass no core: sem suporte sobra só o degradê, em vez de
   uma faixa translúcida e sem função. */
@supports (backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px)){
  .vfd-veu{
    backdrop-filter:blur(var(--vfd-veu-blur)) saturate(112%);
    -webkit-backdrop-filter:blur(var(--vfd-veu-blur)) saturate(112%);
  }
}

/* ---- corpo ---------------------------------------------------------------
   Ancorado embaixo: o topo fica para a mídia respirar sob o menu fixo, e o
   texto vive DENTRO da imagem, como manda o §6. */
.vfd-body{
  position:relative;z-index:2;
  min-height:var(--vfd-alt);
  display:flex;flex-direction:column;justify-content:flex-end;
  padding-top:calc(var(--nav-h) + clamp(40px,8vh,88px));
  padding-bottom:clamp(48px,8vh,96px);
}
.vfd-copy{max-width:min(760px,100%)}

/* ---- headline ------------------------------------------------------------
   Reveal por linha: a máscara é o overflow da .vfd-ln, quem se move é o
   span de dentro. Não use data-split — quebrar por palavra desmancha a
   força de uma headline de seis palavras.

   O blur vem junto, e não é enfeite: o §5 do contrato não aceita entrada
   seca. Termina em blur(0), e não em none, pelo mesmo motivo do core — a
   transição sai de um filtro, e none não interpola a partir de filtro. */
.vfd-title{
  font-family:var(--display);font-weight:700;
  font-size:clamp(32px,5.2vw,60px);line-height:1.08;letter-spacing:-.035em;
  color:var(--fg);margin-top:20px;
}
.vfd-ln{display:block;overflow:hidden}
.vfd-ln > span{
  display:block;
  transform:translateY(112%);opacity:0;filter:blur(12px);
  transition:transform .95s var(--ease),opacity .95s var(--ease),filter 1s var(--ease);
}
.vfd.in .vfd-ln > span{transform:none;opacity:1;filter:blur(0)}
.vfd .vfd-ln:nth-child(2) > span{transition-delay:.09s}
.vfd-title em{font-style:italic;font-weight:700;letter-spacing:-.01em;color:var(--accent)}

/* ---- apoio, ações e pé ---------------------------------------------------
   Cascata em delays fixos: 0,52s / 0,70s / 0,88s, depois das duas linhas. É
   o que dá a leitura de "a página se monta" em vez de "tudo apareceu junto".
   Mesma coreografia do irmão hero/full-bleed-media, de propósito: os dois
   abrem a mesma família de página. */
.vfd-desc{
  font-size:var(--fs-body);line-height:1.6;color:var(--fg-2);
  max-width:56ch;margin-top:24px;
  opacity:0;transform:translateY(14px);filter:blur(10px);
  transition:opacity .8s var(--ease) .52s,transform .8s var(--ease) .52s,filter .9s var(--ease) .52s;
}
.vfd.in .vfd-desc{opacity:1;transform:none;filter:blur(0)}

.vfd-cta{
  display:flex;flex-wrap:wrap;gap:14px;
  margin-top:32px;
  opacity:0;transform:translateY(14px);filter:blur(10px);
  transition:opacity .8s var(--ease) .70s,transform .8s var(--ease) .70s,filter .9s var(--ease) .70s;
}
.vfd.in .vfd-cta{opacity:1;transform:none;filter:blur(0)}

/* Régua à esquerda e controle do vídeo à direita, na mesma linha: o botão
   fica onde o olho procura um controle de mídia, e continua DENTRO da coluna
   do .wrap — sem inset calculado e sem ninguém mexer em padding-inline. */
.vfd-pe{
  display:flex;align-items:flex-end;justify-content:space-between;gap:24px;
  margin-top:clamp(38px,6vh,68px);
  border-top:1px solid var(--rule);padding-top:24px;
  opacity:0;transform:translateY(14px);filter:blur(10px);
  transition:opacity .8s var(--ease) .88s,transform .8s var(--ease) .88s,filter .9s var(--ease) .88s;
}
.vfd.in .vfd-pe{opacity:1;transform:none;filter:blur(0)}

/* Movimento reduzido: os quatro blocos acima nascem no estado final, sem
   transição. Fica COLADO às regras que desliga, como manda o §6.6 — e é
   preciso escrever aqui porque o @media do core cobre só [data-rv], .w e
   .ttl, e esta peça não usa nenhum dos três.

   O core precisa forçar a prioridade nessas regras porque ele não sabe o que
   o componente vai escrever depois dele. Aqui não precisa, e por isso não
   força: os dois seletores valem o mesmo e este vem depois no mesmo arquivo.
   Se algum dia precisar, o token está errado (§6.2). */
@media(prefers-reduced-motion:reduce){
  .vfd-ln > span,
  .vfd-desc,.vfd-cta,.vfd-pe{
    opacity:1;transform:none;filter:none;transition:none;
  }
}
/* Sem JavaScript não existe quem ponha .in, e os quatro blocos ficariam
   invisíveis para sempre: o vídeo tocaria sobre uma dobra vazia. Mesma
   consulta e mesmo motivo do core/motion.css — só que ali ela protege os
   ganchos globais, e aqui protege o portão do próprio componente. */
@media(scripting:none){
  .vfd-ln > span,
  .vfd-desc,.vfd-cta,.vfd-pe{
    opacity:1;transform:none;filter:none;transition:none;
  }
}

/* ---- régua de números ---------------------------------------------------- */
.vfd-regua{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;flex:1 1 auto}
.vfd-k{font-family:var(--display);font-weight:700;font-size:18px;color:var(--accent);letter-spacing:-.02em}
.vfd-v{font-size:12px;line-height:1.7;color:var(--fg-muted);margin-top:7px;letter-spacing:.02em}

/* ---- controle do vídeo ---------------------------------------------------
   Veste .btn-disc e .btn-play do core. Sobra UMA linha aqui, e é só um
   parafuso: 40px de disco em vez dos 42px do padrão, porque o botão divide a
   fileira com a régua de três números e o pé tem de caber na dobra.

   O QUE SAIU DAQUI, na promoção das duas primitivas (2026-09-21):
     - .vfd-toggle[hidden]{display:none} — a armadilha do §6.5 continua de pé,
       mas quem a desarma agora é a própria primitiva, em .btn-play[hidden],
       que é um seletor MAIS específico que o .btn-disc{display:grid} e vence
       em qualquer ordem de carregamento (§6.4). Repetir a regra aqui seria
       copiar core;
     - .vfd-ico{width:15px;height:15px} — 15px é o padrão do --play-ico;
     - a troca de ícone por .vfd.is-tocando na RAIZ, que custava ao componente
       uma classe de estado global só para desenhar um ícone. Agora quem
       comanda é [data-pausado] no próprio botão. */
.vfd-toggle{--disc-size:40px}

/* ---- variação: banner de página interna ---------------------------------
   Mesma linguagem, meia altura, sem régua e sem segundo botão. */
.vfd-curto{--vfd-alt:58vh;--vfd-veu-alt:72%}
@supports (height:100svh){.vfd-curto{--vfd-alt:58svh}}

/* ---- mobile --------------------------------------------------------------
   TODA regra aqui existe para a dobra fechar dentro da tela, e o número é
   medido: a peça abria em 1000px num iPhone de 844, com a régua e o botão
   abaixo da linha de corte. Um hero que não caiba na primeira tela não é um
   hero. As quatro economias, na ordem em que rendem:

     régua e botão na MESMA linha            -60px
     folga do topo reduzida ao menu + 24px   -43px
     respiro do pé (67px -> 36px)            -31px
     respiro acima da régua (51px -> 20px)   -30px

   Somam 164px e devolvem a dobra a 836px medidos, com 8px de folga contra a
   janela de 844. O irmão hero/full-bleed-media fecha em 895px no mesmo
   aparelho, ou seja NÃO cabe — e não por acaso: a régua dele é 2x2 e a daqui
   é uma coluna, que verticaliza mais e obrigou a medir. */
@media(max-width:760px){
  /* --vfd-video-op cai de .46 para .38 aqui, e o motivo é contraste medido,
     não estética: com a copy ocupando quase toda a dobra, o apoio sobe até a
     faixa mais clara do scrim. Contra um quadro branco chapado ele mede 3,76
     em .46 e 4,61 em .38 — a tabela está no topo do arquivo. */
  .vfd{--vfd-veu-alt:74%;--vfd-veu-blur:8px;--vfd-video-op:.38}
  .vfd-body{
    padding-top:calc(var(--nav-h) + 24px);
    padding-bottom:36px;
  }
  /* Três colunas de 96px partem "Política de investimentos" em quatro
     linhas. Em coluna única, chave e rótulo ficam lado a lado. */
  .vfd-pe{margin-top:20px}
  .vfd-regua{grid-template-columns:1fr;gap:12px}
  .vfd-item{display:grid;grid-template-columns:6.2em 1fr;gap:14px;align-items:baseline}
  .vfd-v{margin-top:0}
}

script.js · 142 linhas · 6,7 KB

/* ==========================================================================
   HERO — vídeo em loop como superfície · prefixo vfd-

   O QUE ESTE SCRIPT FAZ, EM ORDEM:
     1. põe .in na raiz, no primeiro quadro depois da pintura — é essa classe
        que abre a cascata (linha, linha, apoio, ações, régua). Sem script o
        CSS entrega os cinco prontos, via @media(scripting:none);
     2. decide se o vídeo PODE tocar, e para tudo se não puder;
     3. entrega o botão de pausa ao LIB.pausavel do core, que o mostra (ele
        nasce [hidden], porque sem script seria um controle morto), troca o
        estado no clique, escreve data-pausado — de onde o .btn-play tira o
        ícone — e leva o rótulo do markup para o aria-label;
     4. mantém esse controle colado ao estado real do <video>.

   A FONTE DA VERDADE AQUI É O <video>, NÃO UM BOOLEANO NOSSO. É o que faz
   deste o caso difícil do LIB.pausavel: o estado muda por TRÊS caminhos sem
   ninguém clicar — os eventos play, pause e error — mais a conferência do
   autoplay. Todos passam por marcar(), que empurra o valor para o controle
   com ctl.trocar(); o callback do core cuida só do clique.

   DUAS RAZÕES PARA O VÍDEO NÃO TOCAR, e as duas param de verdade:

     movimento reduzido — vídeo de fundo é movimento que a pessoa não pediu, e
       o contrato promete que `reduce` desliga o que anda sozinho. Isto não é
       gentileza: é a regra. O que NÃO se desliga é o botão — quem apertar
       play pediu, e pedir continua valendo sob reduce.

     economia de dados — navigator.connection.saveData é a pessoa dizendo ao
       sistema operacional que não quer baixar megabyte. Um loop de fundo é a
       primeira coisa que devia obedecer. A API não existe no Safari nem no
       Firefox, e a ausência dela cai no comportamento padrão (tocar), nunca
       em erro.

   POR QUE A LINHA DE CORTE DE CONEXÃO PARA NO 2G: effectiveType "3g" é onde
   boa parte do acesso móvel brasileiro se reporta, e cortar o vídeo ali
   removeria a peça justamente do público para quem ela foi desenhada. Quem
   quiser ser mais duro troca a expressão por /2g|3g/ — está numa linha só, de
   propósito.

   COMO O VÍDEO VOLTA A MOSTRAR O POSTER: pause() sozinho deixa na tela o
   último quadro pintado, não o poster. load() devolve o elemento ao estado
   inicial, e com ele o poster — e com preload='none' escrito antes, load()
   não busca byte nenhum. Os três passos andam juntos.

   AUTOPLAY RECUSADO NÃO DISPARA EVENTO NENHUM: nem play, nem pause, nem
   error. Por isso o rótulo inicial é OTIMISTA (o markup pediu autoplay) e uma
   conferência única, depois do prazo em que qualquer autoplay já teria
   começado, corrige quando a política recusou. Sem ela o botão diria "Pausar"
   para um vídeo parado, para sempre.
   ========================================================================== */

(function () {

  var raizes = document.querySelectorAll('[data-vfd]');
  if (!raizes.length) return;                       // guard silencioso

  /* Prazo da conferência do autoplay. Longo o bastante para o navegador ter
     decidido, curto o bastante para o rótulo errado não ser lido. */
  var CONFERIR = 1400;

  function reduzido() {
    if (window.LIB && typeof window.LIB.reduce === 'boolean') return window.LIB.reduce;
    return matchMedia('(prefers-reduced-motion: reduce)').matches &&
           location.search.indexOf('motion=on') < 0;
  }

  function poupandoDados() {
    var c = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
    if (!c) return false;                           // sem a API: segue o padrão
    if (c.saveData) return true;
    return /(^|-)2g$/.test(c.effectiveType || '');
  }

  function montar(raiz) {
    var video = raiz.querySelector('[data-vfd-video]');
    var botao = raiz.querySelector('[data-vfd-toggle]');

    /* ---- entrada ----
       Dois rAF encadeados garantem que o estado inicial já pintou antes de a
       transição ligar; com um só, o texto às vezes aparece pronto. O
       setTimeout é a rede para aba aberta em segundo plano, onde o rAF não
       roda — e é por isso que o rAF aqui não precisa consultar reduce: ele
       não anima nada, só abre o portão de um CSS que o próprio @media já
       desligou. */
    requestAnimationFrame(function () {
      requestAnimationFrame(function () { raiz.classList.add('in'); });
    });
    setTimeout(function () { raiz.classList.add('in'); }, 450);

    if (!video) return;

    function parar() {
      video.autoplay = false;
      video.preload = 'none';
      video.pause();
      try { video.load(); } catch (e) {}            // volta ao poster
    }

    function tocar() {
      var p = video.play();
      /* play() devolve promessa em navegador moderno e nada em navegador
         antigo, e a promessa REJEITA quando a política de autoplay recusa —
         rejeição não tratada viraria erro vermelho no console. */
      if (p && p['catch']) p['catch'](function () { marcar(false); });
    }

    /* O CONTROLE VEM DO CORE. Ele mostra o botão, escreve data-pausado e lê
       os rótulos de data-rot-pausar / data-rot-tocar — a copy fica no markup.

       O callback abaixo é IDEMPOTENTE de propósito: o core o dispara em toda
       troca, inclusive nas que vêm de marcar(), e sem as duas perguntas o
       evento 'pause' do vídeo mandaria pausar o vídeo de novo.

       A pergunta pelo LIB não é preciosismo: colado sem o motion.js o
       componente ainda abre, e o botão fica [hidden] como nasceu — que é o
       certo, porque sem o core ele não comandaria nada. */
    var ctl = (window.LIB && window.LIB.pausavel)
      ? window.LIB.pausavel(botao, function (pausado) {
          if (pausado) { if (!video.paused) video.pause(); }
          else if (video.paused) tocar();
        })
      : null;

    function marcar(tocando) { if (ctl) ctl.trocar(!tocando); }

    var travado = reduzido() || poupandoDados();
    if (travado) parar();

    video.addEventListener('play', function () { marcar(true); });
    video.addEventListener('pause', function () { marcar(false); });
    /* Arquivo que não carrega — caminho errado, 404, codec que o navegador
       recusa — é o erro mais provável que um consumidor comete, e ele NÃO
       passa pelo pause: o <video> dispara 'error' e fica em paused=false. Sem
       esta linha o botão diria "Pausar" para sempre, sobre o poster parado. */
    video.addEventListener('error', function () { marcar(false); });

    marcar(!travado && !!video.autoplay);
    setTimeout(function () { marcar(!video.paused); }, CONFERIR);
  }

  for (var i = 0; i < raizes.length; i++) montar(raizes[i]);
})();

Abertura de página inteira em que a superfície é vídeo: um loop curto sangrado até a borda, escurecido por dois gradientes cruzados e por um véu desfocado com máscara em degradê, com a headline entrando linha por linha por dentro de uma máscara e o conteúdo ancorado na base — dentro da mídia, nunca abaixo dela. No pé, uma régua opcional de três números e o controle que para o loop.

O problema editorial que ela resolve é curto de descrever e caro de executar: atmosfera que uma foto não dá. Uma sala que respira, luz que atravessa uma janela, papel que se move — coisas que dizem "esta casa existe e é assim" antes de qualquer palavra. O que decide a peça, porém, não é o desenho: é a decisão de performance. Vídeo de fundo é a coisa mais cara que uma home pode carregar, e por isso metade desta ficha é sobre o arquivo, não sobre o CSS.

O preview mostra o POSTER, parado. A biblioteca não versiona vídeo (§11), e os dois <source> do markup estão comentados com a instrução. Isso não é uma limitação do preview: é exatamente o estado de fallback que o componente promete — e é o que a pessoa vê se o vídeo falhar, se ela tiver movimento reduzido ligado ou se estiver economizando dados.


Quando usar

Quando não usar

Os três abrem <dialog> e ficam no miolo. Este é o único que usa vídeo como superfície, e o único que abre a página. - São três frentes e nenhuma pode ser a primeira. hero/slideshow-cinematico. - O topo precisa mostrar interface. hero/duas-colunas-produto tem o palco com cards sobre a foto. - O que convence é a frase, e a mídia só decoraria. hero/centrado-prova-social — texto centrado, prova social e um botão, sem asset nenhum. - A página tem formulário acima da dobra. Mídia que se move ao lado de um campo de texto rouba a atenção do campo. Vale para o slideshow e vale em dobro aqui, porque o movimento é contínuo. - O cliente não tem quem exporte o arquivo. Sem alguém para rodar o ffmpeg da especificação e conferir a emenda do loop, o resultado previsível é um MOV de 40 MB no <source> — e aí a peça não é lenta, é quebrada.


Padrão editorial

Slot Regra Referência
Olho (.eyebrow) 2 a 3 palavras. Categoria, não benefício. Opcional. "Assessoria independente"
Headline (.vfd-title) 6 a 12 palavras em 2 linhas, uma .vfd-ln cada, 3 a 6 palavras por linha, quebra escrita à mão. Uma palavra em <em> — só uma. "A independência que / seu patrimônio exige."
Apoio (.vfd-desc) 1 parágrafo, 25 a 42 palavras, teto de 56ch. —
Ação primária (.vfd-cta .btn-primary) 2 a 4 palavras. Verbo no imperativo + objeto. Nunca "Saiba mais". "Agende um diagnóstico"
Ação secundária (.vfd-cta .btn-ghost) 2 a 4 palavras. Opcional. "Conheça a casa"
Chave do número (.vfd-k) 3 a 8 caracteres. 3 itens ou nenhum. Opcional. "100%" · "SP · POA" · "Comitê"
Rótulo do número (.vfd-v) 20 a 56 caracteres, em 2 linhas de até 28, quebradas com <br>. Opcional. "Honorários acordados, / sem comissão de produto"
Rótulo do botão tocando (.vfd-toggle, atributo data-rot-pausar) 3 a 5 palavras. "Pausar o vídeo de fundo"
Rótulo do botão parado (.vfd-toggle, atributo data-rot-tocar) 3 a 5 palavras. "Retomar o vídeo de fundo"

Os dois rótulos do botão moram em ATRIBUTO, não no conteúdo do nó. O script escreve o valor no aria-label conforme o estado do vídeo. Quem escrever a copy dentro do <button> vai vê-la sobreviver ao primeiro carregamento e desaparecer no primeiro clique, sem nada acusar.

Sobre a headline: duas linhas é estrutural. A terceira desmancha a cascata (o nth-child(2) já carrega o delay) e empurra a régua para fora da dobra — em celular, medido, a dobra fecha com 8px de folga.

Sobre a régua: três itens ou nenhum, e o motivo é geométrico, não editorial: aqui ela divide a linha do pé com o botão de pausa, e o quarto item aperta as colunas. Ela responde "por que confiar" — modelo de remuneração, praça, governança, certificação, tempo de casa. Nunca "quanto rende" (ver Compliance).

Sobre o apoio: é mais curto que o do irmão de foto (25–42 contra 25–45 palavras, teto de 56ch contra 58ch). Não é capricho: aqui o texto disputa a leitura com uma imagem que se move.


Especificação dos assets

São dois arquivos, e o segundo não é opcional.

O vídeo

Duração 8 a 12 s. Abaixo disso o loop lê como GIF e a emenda volta demais; acima, são bytes para um material que ninguém assiste — a pessoa rola em cinco segundos
Resolução 1920×1080. Não 4K: o vídeo roda a 46% de opacidade atrás de um véu desfocado, onde nenhum detalhe de 4K sobrevive, e quadruplica o peso
Taxa 24 ou 25 fps. Não há ação a resolver num fundo, e 24 corta ~20% do bitrate contra 30
Peso alvo até 1,8 MB no mp4 (a conta: 1400 kbps × 10 s = 1,75 MB). Ideal perto de 1,5 MB. O mesmo material mal exportado passa de 12 MB
Formatos webm/VP9 primeiro no markup, mp4/H.264 depois. O navegador pega o primeiro que sabe tocar, e o VP9 costuma sair ~25% menor
Áudio nenhuma trilha. O vídeo é muted para sempre; trilha de áudio é peso morto
# mp4/H.264 — o que todo navegador toca
ffmpeg -i origem.mov -t 10 -an \
  -vf "scale=1920:-2,fps=24" \
  -c:v libx264 -profile:v high -level 4.0 -pix_fmt yuv420p \
  -preset slower -crf 28 -maxrate 1400k -bufsize 2800k \
  -movflags +faststart \
  hero-fundo.mp4

# webm/VP9 — menor, entra antes no markup
ffmpeg -i origem.mov -t 10 -an \
  -vf "scale=1920:-2,fps=24" \
  -c:v libvpx-vp9 -crf 36 -b:v 0 -row-mt 1 \
  hero-fundo.webm

# o poster: o QUADRO ZERO do vídeo já exportado, nunca outra foto
ffmpeg -i hero-fundo.mp4 -frames:v 1 -q:v 2 poster.png
cwebp -q 75 -resize 1600 0 poster.png -o hero-fundo-poster.webp

Três flags que parecem detalhe e não são:

O que o vídeo NÃO pode ter

Como o loop tem de fechar

O salto na emenda é o defeito que mais aparece e o que ninguém percebe na hora de exportar.

  1. Primeiro quadro igual ao último. Escolha material cuja composição volte ao ponto de partida: panorâmica lenta sobre assunto estático, água, tecido, luz mudando pouco. É a saída mais barata e a que dá o melhor resultado.
  2. Ou vai-e-volta no export, se o material não fecha: -filter_complex "[0:v]split[a][b];[b]reverse[r];[a][r]concat". A emenda passa a ser matematicamente invisível, ao custo de dobrar os quadros — para o mesmo peso, use metade da duração.
  3. Nunca termine em fade para preto. No reinício, o loop pisca.
  4. Confira assistindo a três voltas completas. Se você viu a emenda uma vez, ela está lá.

O poster


Compliance

A peça é uma abertura institucional e não devolve número calculado, mas pisa em terreno regulado em três pontos.


Como usar

<link rel="stylesheet" href="core/tokens.css">
<link rel="stylesheet" href="core/temas/<projeto>.css">
<link rel="stylesheet" href="core/base.css">
<link rel="stylesheet" href="core/motion.css">
<link rel="stylesheet" href="componentes/hero/video-fundo/estilo.css">
...
<script src="core/motion.js"></script>
<script src="componentes/hero/video-fundo/script.js"></script>

Cole o markup.html como primeiro filho do <body>, descomente os dois <source> e aponte o poster para o WebP.

O escopo de tom NÃO se troca aqui, ao contrário do irmão de foto. A peça é escura por construção: o scrim, o véu e a emenda leem --dark-rgb porque o texto tem de sobreviver a qualquer quadro, e trocar .sec-dark por .sec-paper ou .sec-light deixa tinta escura sobre mídia escurecida — ilegível, e nenhum validador acusa. Por isso o realce em itálico e a chave da régua leem --accent direto e não --accent-ink: --accent-ink existe para o accent usado como tinta num escopo claro, e nos dois escopos escuros os dois valores são idênticos. Num projeto claro, a dobra de abertura é hero/centrado-prova-social ou o irmão de foto.

Os quatro atributos do <video> — muted, playsinline, autoplay, loop — andam juntos e nenhum é opcional. Sem muted nenhum navegador toca sem gesto; sem playsinline o iPhone abre o vídeo em tela cheia por cima do site.

preload="metadata" é escolha, e as outras duas não servem: auto põe megabytes na frente do poster, que é o LCP; none é ignorado na presença de autoplay, e escrevê-lo documentaria uma intenção que o navegador descarta.

Ganchos

Gancho Papel
[data-vfd] raiz — o script procura por ele, nunca por id, e itera sobre todos
[data-vfd-video] o <video> de fundo
[data-vfd-toggle] o botão de pausa, vestindo as primitivas .btn-disc e .btn-play. Nasce [hidden]; quem o mostra é o LIB.pausavel do core
data-rot-pausar / data-rot-tocar os dois rótulos, no atributo. Nomes do core — é ele quem lê e escreve o aria-label
[data-pausado] no próprio botão, escrito pelo core: espelha video.paused e é de onde o .btn-play tira qual ícone aparece. Substituiu a classe .vfd.is-tocando na raiz
.vfd.in portão da cascata de entrada, posto pelo script
--vfd-alt altura da dobra (100svh, 58vh na variação curta)
--vfd-video-op opacidade do vídeo — o parafuso do contraste, ver a tabela no topo do estilo.css
--vfd-veu-alt / --vfd-veu-blur altura e desfoque do véu. --vfd-veu-blur:0 devolve a GPU

Variações


Gotchas


Histórico

Origem Quando O quê
autoral 2026-09-06 Construído do zero. Padrão de domínio comum, sem referência única
migração 2026-09-21 A fiação do botão de pausa passou ao core: .btn-play (core/base.css) e LIB.pausavel (core/motion.js). Saíram deste componente a troca de ícone por .vfd.is-tocando na raiz, o [hidden] desligado à mão, o tamanho do ícone e a leitura dos rótulos pelo script. Os atributos de copy perderam o prefixo: data-vfd-rot-* virou data-rot-*, que é o par que o core lê. Por quê: este componente e o hero/slideshow-cinematico faziam a mesma coisa cada um do seu jeito, e a leva de etapas, filme e depoimentos em rodízio traria mais três cópias — o §6 manda promover na terceira

Origem. O vídeo em loop como superfície da primeira dobra aparece em home de escritório de advocacia, de gestora, de incorporadora e em página institucional de serviço de alto ticket — padrão de domínio comum, sem dono. Nada foi copiado. O registro consolidado vai em projetos/autorais.md.

Por que existe ao lado do irmão. O hero/full-bleed-media tem a mesma anatomia: mídia sangrada, scrim, headline em linhas, régua de números opcional. A divisão não é de desenho, é de asset e de consequência. Foto é um arquivo que qualquer cliente entrega; loop que fecha é produção. Foto não precisa de poster, nem de quatro atributos que só funcionam juntos, nem de um controle de pausa que a WCAG exige, nem de duas razões para não tocar. Foi por isso que a variação "vídeo no lugar da foto" da ficha dele nunca saiu de experimental: trocar a tag não resolve nada do que está nesta ficha. Ele continua sendo a escolha certa na maioria dos casos — e é isso que faz a distinção honesta.

Decisões da tradução para o contrato. O @media(scripting:none) local, porque o portão da cascata é uma classe do próprio componente e o core só protege os ganchos dele. A cascata carregando blur indo a zero, que o §5 exige e o irmão não faz. pause() + preload=none + load() como conjunto, porque só o último devolve o poster. --vfd-video-op calibrado por medição de contraste contra um quadro branco chapado, para a peça ser WCAG AA contra qualquer vídeo e não só contra um vídeo bem-comportado. E o [hidden] do botão, que precisou da linha do §6.5 porque o display vinha da primitiva .btn-disc — linha que em 2026-09-21 passou ao core junto com o resto da fiação.

O que ficou anotado para a contagem do §6. Esta é a segunda cópia de dois padrões do irmão: a máscara de linha da headline (overflow:hidden no bloco, translateY(112%) no span de dentro) e a cascata por delay fixo disparada por uma classe na raiz. Na terceira, promova ao core — a máscara de linha em especial, que é a assinatura de entrada de headline da casa.

Calibragem no primeiro uso. Espere ajustar dois números contra o material real: --vfd-video-op, contra o brilho médio do loop (a tabela do estilo.css diz o que se ganha e o que se perde), e object-position, contra o enquadramento — o padrão 50% 46% sobe o centro de interesse para acima da faixa da copy.