Pular para o conteúdo
Biblioteca BTN-04

Botão de vidro líquido

Botão transparente que DISTORCE o que está atrás dele, com ruído SVG empurrando os pixels do fundo.

usa o BTN-04
abrir sozinho ↗
Código
BTN-04 · alias botao-vidro-liquido
Função
Botão
Fonte
21st.dev · button.tsx — LiquidButton
Recebido
06/10/2026
Status
acervo
Licença
não verificada

O código

botao-vidro-liquido.css + markup.html 124 linhas · 2 arquivo(s) numa caixa só
/* ===== botao-vidro-liquido.css ===================================================== */

/* BTN-04 · Botão de vidro líquido — reescrito do original do 21st.dev sem React e sem Tailwind.

   O truque não é transparência: é DISTORÇÃO. Um filtro SVG (`feTurbulence` + `feDisplacementMap`)
   empurra os pixels do que está atrás do botão, como vidro grosso irregular. O `backdrop-filter`
   aponta para esse filtro por id.

   ⚠️ **O `isolation: isolate` no BOTÃO não é enfeite — sem ele o efeito não existe.** Medido em
   06/10/2026: uma camada com `z-index: -1` dentro de um pai que não isola tem o backdrop VAZIO, e
   o `backdrop-filter` não desenha nada. É assim que o original vem (`-z-10` dentro de um
   `relative`), e por isso o vidro dele **não renderiza em navegador nenhum**. Pondo o
   `isolation` no pai, o botão vira o backdrop root e a camada passa a enxergar o que está atrás.
   Medições: `z-index:-1` sozinho → nada · `z-index:-1` + `isolation` na própria camada → nada ·
   `isolation` no pai → renderiza · `z-index:0` → renderiza.

   A pilha de sombras é o que desenha a "borda de vidro": duas sombras internas opostas fazem o
   bisel, duas mais fechadas fazem o brilho da aresta, e duas largas fazem o corpo do vidro. */
.c-vidro {
  --c-liq-tinta: #ebebeb;
  --c-liq-raio: 6px;
  --c-liq-altura: 56px;
  --c-liq-padding: 40px;

  position: relative;
  /* OBRIGATÓRIO: faz do botão o backdrop root. Sem isto, a lente de z-index -1 tem o backdrop
     vazio e o filtro não desenha. Foi o defeito do original. */
  isolation: isolate;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  height: var(--c-liq-altura);
  padding: 0 var(--c-liq-padding);
  border: 0;
  border-radius: var(--c-liq-raio);
  background: transparent;
  color: var(--c-liq-tinta);
  font: 500 14px/1 inherit;
  white-space: nowrap;
  text-decoration: none;
  cursor: pointer;
  transition: transform .3s ease;
}
.c-vidro:hover { transform: scale(1.05); }
.c-vidro[disabled] { opacity: .5; pointer-events: none; }
.c-vidro:focus-visible { outline: 3px solid rgba(232, 173, 50, .5); outline-offset: 2px; }

/* a camada que distorce o fundo. Fica ATRÁS do rótulo (z-index -1 com isolation no pai). */
.c-vidro__lente {
  position: absolute;
  z-index: -1;
  inset: 0;
  overflow: hidden;
  border-radius: var(--c-liq-raio);
  /* o isolation NÃO vai aqui: na própria camada ele cria um backdrop root vazio e mata o efeito.
     Vai no pai (.c-vidro). Medido. */
  -webkit-backdrop-filter: url("#c-vidro-filtro");
  backdrop-filter: url("#c-vidro-filtro");
}
/* a pilha de sombras que desenha a borda do vidro. O original usava rounded-full aqui e
   rounded-md no botão — era incoerência dele; aqui as duas seguem --c-liq-raio. */
.c-vidro__borda {
  position: absolute;
  z-index: 0;
  inset: 0;
  pointer-events: none;
  border-radius: var(--c-liq-raio);
  box-shadow:
    0 0 8px rgba(0, 0, 0, .03),
    0 2px 6px rgba(0, 0, 0, .08),
    inset 3px 3px 0.5px -3.5px rgba(255, 255, 255, .09),
    inset -3px -3px 0.5px -3.5px rgba(255, 255, 255, .85),
    inset 1px 1px 1px -0.5px rgba(255, 255, 255, .6),
    inset -1px -1px 1px -0.5px rgba(255, 255, 255, .6),
    inset 0 0 6px 6px rgba(255, 255, 255, .12),
    inset 0 0 2px 2px rgba(255, 255, 255, .06),
    0 0 12px rgba(0, 0, 0, .15);
  transition: box-shadow .3s ease;
}
.c-vidro__rotulo { position: relative; z-index: 1; pointer-events: none; }
.c-vidro svg { width: 16px; height: 16px; flex: none; }

/* tamanhos do original */
.c-vidro--p  { --c-liq-altura: 32px; --c-liq-padding: 16px; font-size: 12px; }
.c-vidro--m  { --c-liq-altura: 36px; --c-liq-padding: 16px; }
.c-vidro--g  { --c-liq-altura: 40px; --c-liq-padding: 24px; }
.c-vidro--gg { --c-liq-altura: 48px; --c-liq-padding: 32px; }

/* o filtro é caro: com "reduzir movimento" o botão para de crescer, mas a lente fica —
   ela não se move, é estática */
@media (prefers-reduced-motion: reduce) {
  .c-vidro, .c-vidro__borda { transition: none; }
  .c-vidro:hover { transform: none; }
}


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

<!-- BTN-04 · Botão de vidro líquido.
     O <svg> do filtro vive UMA VEZ por página, em qualquer lugar do body, e todos os botões o
     referenciam pelo id. Sem ele, o backdrop-filter não resolve e o botão fica só transparente.
     Precisa de algo ATRÁS para distorcer — sobre fundo liso o efeito não aparece. -->
<button type="button" class="c-vidro">
  <span class="c-vidro__lente" aria-hidden="true"></span>
  <span class="c-vidro__borda" aria-hidden="true"></span>
  <span class="c-vidro__rotulo">Ver valores</span>
</button>

<!-- uma vez por página -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <defs>
    <filter id="c-vidro-filtro" x="0%" y="0%" width="100%" height="100%" color-interpolation-filters="sRGB">
      <!-- ruído fractal: é o mapa que diz para onde empurrar cada pixel -->
      <feTurbulence type="fractalNoise" baseFrequency="0.05 0.05" numOctaves="1" seed="1" result="ruido"/>
      <!-- borrar o ruído tira o granulado e deixa a distorção ondulada, de vidro -->
      <feGaussianBlur in="ruido" stdDeviation="2" result="ruidoBorrado"/>
      <!-- scale 70 é a força do empurrão: abaixo de ~30 quase não se vê, acima de ~120 vira sopa -->
      <feDisplacementMap in="SourceGraphic" in2="ruidoBorrado" scale="70" xChannelSelector="R" yChannelSelector="B" result="deslocado"/>
      <feGaussianBlur in="deslocado" stdDeviation="4" result="final"/>
      <feComposite in="final" in2="final" operator="over"/>
    </filter>
  </defs>
</svg>

Já rodou em

Ainda não rodou em peça publicada.

Exige

  • react
  • @radix-ui/react-slot
  • class-variance-authority (cva)
  • cn
  • Tailwind (classes arbitrárias longas de shadow-[...])

Como se configura

  • --c-liq-tinta
  • --c-liq-raio
  • --c-liq-altura
  • --c-liq-padding

A ficha

O que o 21st.dev entregou

Veio num arquivo com TRÊS componentes. O mesmo .tsx traz o Button (shadcn padrão, com a variante cool), o LiquidButton e o MetalButton. O original/ de cada um dos três é esse arquivo inteiro, igual — é o que foi colado, e cortá-lo seria editar o original.

Três defeitos do arquivo como veio, nenhum corrigido em original/:

  • O export { Button, buttonVariants, liquidbuttonVariants, LiquidButton } aparece antes de liquidbuttonVariants e LiquidButton serem declarados. Funciona por hoisting de export em ESM, mas é artefato de cópia, não escolha.
  • O MetalButton não entra nesse export: tem o próprio export const. E GlassFilter, ShineEffect, colorVariants e metalButtonVariants não são exportados — quem copiar só o LiquidButton leva o GlassFilter junto sem perceber.
  • bg-linear-to-t e inset-shadow-2xs (na variante cool) são classes do Tailwind v4. Em v3 elas não existem, e o degradê do botão simplesmente não aparece.

O componente

Dependências: react, @radix-ui/react-slot, cva, cn, Tailwind.

O truque não é transparência — é distorção. Um filtro SVG empurra os pixels do que está atrás do botão:

  1. feTurbulence (fractalNoise, baseFrequency 0.05, 1 oitava) gera um mapa de ruído;
  2. feGaussianBlur (σ=2) borra esse ruído — é o que troca granulado por ondulação de vidro;
  3. feDisplacementMap (scale=70) usa o canal R para o deslocamento horizontal e o B para o vertical, empurrando cada pixel do fundo;
  4. feGaussianBlur (σ=4) suaviza o resultado.

O backdrop-filter: url("#container-glass") é o que liga a camada ao filtro.

A pilha de sombras é a segunda metade do efeito: nove sombras numa box-shadow só. Duas internas opostas de 3 px fazem o bisel; duas de 1 px fazem o brilho da aresta; duas de 6 px de espalhamento fazem o corpo do vidro; três externas assentam o botão.

Tamanho em repouso: o padrão é xxl — 56 px de altura e 40 px de lateral. Hover cresce 5% em 300 ms.


O que a adaptação da OCTA mudou

  • ⚠️ O isolation foi da camada para o botão — sem isso o efeito não existe, e é assim que o original vem.
  • O filtro SVG saiu de dentro do componente e virou um bloco único por página. No original cada botão renderiza o próprio <GlassFilter /> — com três botões na tela, são três <filter> com o mesmo id, e o navegador fica com o primeiro. Funcionava por acidente.
  • O raio ficou coerente: o original usa rounded-full na camada de sombra e rounded-md no botão, o que arredondava o brilho mais que a borda.
  • Os ids ganharam prefixo c- para não colidirem com outro filtro da página.

Lacunas do original

⚠️ O vidro do original não renderiza — em navegador nenhum

Medido em 06/10/2026, e é o achado que mais importa deste componente. A camada do filtro vem com -z-10 dentro de um pai que não isola. Nessa posição o backdrop fica vazio, e o backdrop-filter não desenha nada — nem com url(), nem com blur(), nem com invert().

EstruturaRenderiza?
z-index: -1 sozinhonão
z-index: -1 + isolation: isolate na própria camada ← o do originalnão
z-index: -1 + isolation: isolate no pai ← o consertosim
z-index: 0sim

A adaptação põe o isolation no botão. É a diferença entre o componente funcionar e não funcionar.

O que foi medido depois do conserto

  • Chromium: renderiza.
  • WebKit da Playwright: não renderiza. Mas não dá para concluir daí que o Safari não suporta: num teste mínimo, sem z-index nenhum, o backdrop-filter não funcionou em nenhuma forma nesse build — nem blur(10px). Isso aponta para o build headless, não para o motor. Falta medir em Safari de verdade, e até lá o status honesto é não medido.

As outras

  • Precisa de algo atrás para distorcer. Sobre fundo liso o componente não tem o que mostrar.
  • No original, cada botão emite um <filter id="container-glass"> próprio. Vários na mesma página = ids duplicados, e o navegador fica com o primeiro. Funcionava por acidente.
  • O rótulo é pointer-events-none e a variante default não declara foco próprio — o anel depende do que vier de fora.
  • feDisplacementMap com scale=70 é caro: numa página com muitos, custa quadro.