card-video-texto — card com vídeo e painel de texto

Variante do componente card com vídeo em loop autoplay como mídia de fundo, botão pause/play que aparece no hover, e painel de info contendo um texto descritivo (.card-resumo). Para variante com lista de sub-links, veja card-video-link.

CÓDIGO COMPLETO

index.html

<!DOCTYPE html>
<html lang="pt-BR">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>card-video — variante texto</title>
    <link rel="stylesheet" href="style.css">
</head>

<body>

    <div class="demo-stage">

        <div class="card" data-href="https://example.com">
            <div class="card-menu btnMenuCard">
                <svg viewBox="0 0 350 350"><circle cx="39" cy="55" r="39"/><circle cx="39" cy="175" r="39"/><circle cx="39" cy="295" r="39"/><path d="M345,21H114c-3,0-5,2-5,5v58c0,3,2,5,5,5h231c3,0,5-2,5-5V26c0-3-2-5-5-5z"/><path d="M345,141H114c-3,0-5,2-5,5v58c0,3,2,5,5,5h231c3,0,5-2,5-5v-58c0-3-2-5-5-5z"/><path d="M345,261H114c-3,0-5,2-5,5v58c0,3,2,5,5,5h231c3,0,5-2,5-5v-58c0-3-2-5-5-5z"/></svg>
            </div>
            <div class="card-info">
                <div class="card-resumo">
                    <strong>Demo</strong>
                    <p>Vídeo em loop autoplay no primeiro hover. Botão pause/play aparece no canto superior esquerdo ao passar o mouse.</p>
                </div>
            </div>
            <div class="card-img">
                <button class="card-pause btnPauseCard" aria-label="Pausar/tocar vídeo">
                    <svg class="ic-pause" viewBox="0 0 24 24"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>
                    <svg class="ic-play" viewBox="0 0 24 24"><path d="M7 4v16l13-8z"/></svg>
                </button>
                <video src="video.mp4#t=0.1" muted loop playsinline preload="auto" data-name="DEMO"></video>
            </div>
            <div class="card-bar">
                <a href="https://example.com" class="card-tag" style="--tag-clr:#10b981;">Demo</a>
            </div>
        </div>

    </div>

    <script src="script.js"></script>
</body>

</html>

style.css

/* Stage só pra visualização standalone */
body {
    margin: 0;
    min-height: 100vh;
    background: #f5f5f7;
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    display: flex;
    align-items: center;
    justify-content: center;
}

.demo-stage {
    width: 100%;
    max-width: 320px;
    padding: 40px;
}

/* --- Card --- */
.card {
    position: relative;
    width: 100%;
    aspect-ratio: 3.5 / 4.5;
    border-radius: 8px;
    overflow: hidden;
    cursor: default;
    background: #1a1a1a;
    box-shadow: 0 2px 8px rgba(0,0,0,0.15);
    transition: transform 0.4s ease, box-shadow 0.4s ease;
}

.card:hover {
    transform: scale(1.06);
    box-shadow: 0 12px 32px rgba(0,0,0,0.25);
    z-index: 1;
}

/* --- Mídia --- */
.card-img {
    position: absolute;
    inset: 0;
    overflow: hidden;
    bottom: 56px;
}

.card-img video {
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center top;
    transition: filter 0.5s ease;
}

/* --- Barra inferior --- */
.card-bar {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    height: 56px;
    background: #f0f0f0;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0 12px;
    box-shadow: 0 -3px 6px rgba(0,0,0,0.15);
    z-index: 2;
}

.card-tag {
    flex: 1;
    text-align: center;
    font-size: 0.85em;
    font-weight: 700;
    color: var(--tag-clr, #555);
    border: 2px solid var(--tag-clr, #555);
    border-radius: 6px;
    padding: 8px 10px;
    white-space: nowrap;
    text-decoration: none;
    cursor: pointer;
    text-transform: uppercase;
    transition: background 0.3s, color 0.3s;
}

.card:hover .card-tag {
    background: var(--tag-clr, #555);
    color: #fff;
}

/* --- Botão menu (3 bolinhas) --- */
.card-menu {
    position: absolute;
    right: 8px;
    top: 8px;
    z-index: 5;
    cursor: pointer;
    opacity: 0;
    transition: opacity 0.3s ease;
    overflow: visible;
}

.card:hover .card-menu {
    opacity: 1;
}

.card-menu svg {
    width: 32px;
    height: 32px;
    fill: #fff;
    filter: drop-shadow(0 0 3px rgba(0,0,0,0.5));
    background: rgba(255,255,255,0.25);
    border-radius: 6px;
    padding: 4px;
    box-sizing: content-box;
    transition: opacity 0.2s;
}

.card-menu.aberto svg {
    opacity: 0;
}

.card-menu::after {
    content: '\00d7';
    position: absolute;
    top: -16px;
    right: -10px;
    width: 52px;
    height: 52px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 36px;
    font-weight: 700;
    color: #fff;
    text-shadow: 0 0 4px rgba(0,0,0,0.5);
    background: rgba(0,0,0,0.35);
    border-radius: 10px;
    opacity: 0;
    transition: opacity 0.2s;
    pointer-events: none;
    cursor: pointer;
}

.card-menu.aberto::after {
    opacity: 1;
}

/* --- Botão pause/play (canto superior esquerdo) --- */
.card-pause {
    position: absolute;
    left: 8px;
    top: 8px;
    z-index: 5;
    cursor: pointer;
    opacity: 0;
    transition: opacity 0.3s ease;
    background: rgba(255,255,255,0.25);
    border: none;
    border-radius: 6px;
    padding: 4px;
    box-sizing: content-box;
    display: flex;
    align-items: center;
    justify-content: center;
}

.card:hover .card-pause {
    opacity: 1;
}

.card:has(.card-info.aberto) .card-pause {
    opacity: 0;
    pointer-events: none;
}

.card-pause svg {
    width: 24px;
    height: 24px;
    fill: #fff;
    filter: drop-shadow(0 0 3px rgba(0,0,0,0.5));
    display: block;
}

.card-pause .ic-pause { display: none; }
.card-pause.playing .ic-pause { display: block; }
.card-pause.playing .ic-play  { display: none; }

/* --- Painel de info (slide da direita) --- */
.card-info {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 56px;
    overflow: hidden;
    width: 100%;
    padding: 0;
    z-index: 4;
    display: flex;
    flex-direction: column;
    justify-content: center;
    transition: transform 0.4s ease, opacity 0.4s ease;
    transform: translateX(100%);
    opacity: 0;
    pointer-events: none;
}

.card-info::before {
    content: "";
    position: absolute;
    inset: 0;
    background: rgba(0,0,0,0.35);
    backdrop-filter: blur(4px);
    opacity: 0;
    transition: opacity 2s ease;
    z-index: -1;
}

.card-info.aberto {
    transform: translateX(0);
    opacity: 1;
    pointer-events: auto;
}

.card-info.aberto::before {
    opacity: 1;
}

.card-resumo {
    position: absolute;
    top: 10px;
    left: 10px;
    right: 10px;
    bottom: 10px;
    font-size: 0.85em;
    line-height: 1.6;
    color: #1a1a1a;
    text-align: left;
    padding: 6px 8px;
    overflow-y: auto;
    background: rgba(255,255,255,0.4);
    border-radius: 10px;
    border: 1px solid rgba(0,0,0,0.08);
    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}

.card-resumo strong {
    display: block;
    margin-bottom: 10px;
}

.card-resumo p {
    margin: 6px 0 0;
}

/* Blur na mídia quando info aberto */
.card-info.aberto ~ .card-img video {
    filter: blur(6px) grayscale(20%);
}

script.js

document.querySelectorAll('.card').forEach(function(card) {
    var btn = card.querySelector('.btnMenuCard');
    var info = card.querySelector('.card-info');

    if (btn && info) {
        btn.addEventListener('click', function(e) {
            e.stopPropagation();
            info.classList.toggle('aberto');
            btn.classList.toggle('aberto');
        });
    }

    var cardHref = card.getAttribute('data-href');
    if (cardHref) {
        card.addEventListener('click', function(e) {
            if (e.target.closest('a[href]') || e.target.closest('.btnMenuCard')) return;
            window.location.href = cardHref;
        });
    }

    var video = card.querySelector('.card-img video');
    if (video) {
        card.addEventListener('mouseenter', function() {
            video.play().catch(function() {});
        }, { once: true });

        var pauseBtn = card.querySelector('.btnPauseCard');
        if (pauseBtn) {
            pauseBtn.addEventListener('click', function(e) {
                e.stopPropagation();
                if (video.paused) video.play().catch(function() {});
                else video.pause();
            });
            video.addEventListener('play',  function() { pauseBtn.classList.add('playing'); });
            video.addEventListener('pause', function() { pauseBtn.classList.remove('playing'); });
        }
    }
});
Como usar — pra quem vai aplicar numa página
0:00 / 0:00

Quando usar

Usar quando:

  • A mídia mostra movimento (clique numa tela, animação de tabela, demo de uso)
  • O loop curto (5–15s) comunica a função do que o card representa
  • Você tem um .mp4 já gerado e quer mostrar uma descrição em texto livre

Não usar quando:

  • A mídia é estática (logo, retrato, ilustração) → use card-image
  • O painel deve mostrar uma lista de sub-links em vez de texto → use card-video-link

HTML mínimo

<div class="card" data-href="...">
    <div class="card-menu btnMenuCard">
        <svg viewBox="0 0 350 350">...3 bolinhas...</svg>
    </div>
    <div class="card-info">
        <div class="card-resumo">
            <strong>Demo</strong>
            <p>Texto descritivo do card.</p>
        </div>
    </div>
    <div class="card-img">
        <button class="card-pause btnPauseCard" aria-label="Pausar/tocar vídeo">
            <svg class="ic-pause" viewBox="0 0 24 24">...</svg>
            <svg class="ic-play" viewBox="0 0 24 24">...</svg>
        </button>
        <video src="video.mp4#t=0.1" muted loop playsinline preload="auto" data-name="DEMO"></video>
    </div>
    <div class="card-bar">
        <a href="..." class="card-tag" style="--tag-clr:#10b981;">Demo</a>
    </div>
</div>

Atributos do <video>

Atributo Por que
src="...#t=0.1" O #t=0.1 força o browser a renderizar o frame em 0.1s em vez de tela preta.
muted Browsers bloqueiam autoplay se o vídeo tiver áudio.
loop Vídeo recomeça automaticamente; tem que ser curto (5–15s).
playsinline iOS Safari não toma fullscreen ao tentar tocar.
preload="auto" Browser baixa o vídeo logo no load (imagem pronta antes do hover).
data-name Nome lógico do vídeo/card. Não controla o texto do botão.
Como funciona por dentro — pra quem quer entender a mecânica
0:00 / 0:00

Anatomia

.card
  ├── .card-menu .btnMenuCard            hamburger top-right (abre painel info)
  ├── .card-info                         painel deslizante (.aberto = visível)
  │   └── .card-resumo                   strong + p de descrição
  ├── .card-img                          vídeo cobrindo o card
  │   ├── .card-pause .btnPauseCard      botão pause/play top-left
  │   │   ├── svg.ic-pause
  │   │   └── svg.ic-play
  │   └── <video muted loop playsinline preload="auto">
  └── .card-bar
      └── a.card-tag                     nome fixo do card

Autoplay no primeiro hover

Vídeo começa pausado. O { once: true } garante que o autoplay só dispara uma vez — depois disso o controle é do botão pause/play:

card.addEventListener('mouseenter', function() {
    video.play().catch(function() {});
}, { once: true });

O .catch(noop) evita warning no console — browsers retornam Promise rejeitada se autoplay for bloqueado pela política do user agent.

Botão pause/play

pauseBtn.addEventListener('click', function(e) {
    e.stopPropagation();
    if (video.paused) video.play().catch(function() {});
    else video.pause();
});
video.addEventListener('play',  function() { pauseBtn.classList.add('playing'); });
video.addEventListener('pause', function() { pauseBtn.classList.remove('playing'); });

O e.stopPropagation() impede que o click propague pro card (que pode ter data-href) — sem isso, tentar pausar acabava redirecionando.

Cross-fade dos ícones via classe .playing

.card-pause .ic-pause { display: none; }
.card-pause.playing .ic-pause { display: block; }
.card-pause.playing .ic-play  { display: none; }

O CSS controla qual SVG mostrar baseado em .playing. O JS só adiciona/remove a classe nos eventos play/pause do video. Mais robusto: se o vídeo termina o loop em momento estranho, o estado visual continua sincronizado.

Botão pause some quando o painel abre

.card:has(.card-info.aberto) .card-pause {
    opacity: 0;
    pointer-events: none;
}

Sem isso, o botão ficaria flutuando sobre o painel aberto e podendo receber click acidental. Quando o painel fecha, a regra :hover reassume e o botão reaparece naturalmente.

Decisões de design

Por que vídeo começa pausado?
Autoplay imediato em todos os cards consome CPU/bateria sem benefício se o usuário não está interessado. O modelo "primeiro hover dá play" é responsivo à intenção: cursor sobre o card já sinaliza interesse.

Por que { once: true } no listener do mouseenter?
Garante que o autoplay só dispara uma vez. Depois disso o controle é manual (botão).

Por que botão pause oculto até hover?
opacity: 0 por padrão, 1 no :hover do card. Mantém o card visualmente limpo e o botão só aparece quando relevante (mouse já está perto).

Por que troca de ícone via classe .playing em vez de JS direto?
CSS controla qual SVG mostrar baseado em .playing. Mais robusto: se o vídeo termina o loop em momento estranho, o estado visual continua sincronizado com o estado real do <video>.

Por que #t=0.1 no src?
Força renderização do primeiro frame antes do user dar hover. Sem isso, o card mostra preto até o vídeo carregar.

Por que mídia só com blur ao abrir o painel?
A versão antiga combinava blur com scale(1.1) pra dar profundidade, mas o movimento da mídia competia com o slide do painel e poluía a leitura. Agora o efeito é puramente óptico: o que estava nítido fica desfocado, ponto.

Comportamento no mobile

  • O botão pause tem opacity: 0 e só vira 1 no :hover do card. Em mobile fica permanentemente invisível.
  • O autoplay no primeiro hover depende do mouseenter, que touch devices não disparam. Em mobile o vídeo fica no frame inicial (#t=0.1) e não toca.
  • Click no card vai direto pro destino via data-href.
  • Mudança de cor da .card-tag no hover não aparece.

Limitação conhecida: se quiser que o vídeo toque no mobile, precisa de outro evento (ex: IntersectionObserver quando o card entra na viewport).