todos os posts

Pare de escrever skeletons na mão

ReactNext.js4 min de leitura

Você provavelmente já escreveu esse trecho de código em algum momento da sua carreira:

<div className="h-4 w-32 animate-pulse rounded bg-gray-200" />

E depois mais uns dez parecidos, empilhados até o formato lembrar vagamente a UI que foi desenhada para estar ali. Funciona no dia em que foi escrito. O problema aparece depois: quando alguém adiciona uma linha a mais ao card, muda o padding, troca o avatar de quadrado pra redondo. O skeleton quase nunca acompanha essas modificações: ele fica lá esquecido, não quebra teste, não gera erro no console, não aparece em code review. Até virar aquele piscar estranho de layout que todo mundo percebe e ninguém prioriza 🤦‍♂️

Hoje eu vim mostrar uma solução "milagrosa" para esse problema. O boneyard resolve isso de uma maneira bem simples e bem intuitiva: em vez de você escrever o skeleton à mão, ele o extrai do seu componente real.

O que vamos fazer

Instalar a biblioteca, envolver um componente e rodar um comando. O uso final fica assim:

import { Skeleton } from "boneyard-js/react";

function ProductPage() {
  const { data, isLoading } = useProduct();

  return (
    <Skeleton name="product-card" loading={isLoading}>
      {data && <ProductCard data={data} />}
    </Skeleton>
  );
}

O que eu mais gosto do boneyard é que você não precisa escrever nenhuma medida na mão. O name é a chave que liga esse ponto da UI ao arquivo de bones gerado.

Como funciona: você marca, o CLI mede, o app importa

São três passos, e vale entender como cada um funciona.

Marcação é o wrap acima. O <Skeleton> marca no componente os pontos que devem virar skeleton.

Medição é o CLI:

npm install boneyard-js
npx boneyard-js build

Com o dev server já rodando, o CLI abre um browser headless, visita a aplicação, encontra cada <Skeleton name="..."> e mede os elementos filhos nos breakpoints padrão: 375, 768 e 1280. Se o componente depende de dados de uma API ou está protegido por autenticação, você passa um fixture, um mock com a mesma estrutura, só que com um conteúdo estático.

Importação é uma linha só, no root da aplicação (no Next.js, por exemplo, o layout.tsx):

import "./bones/registry";

Isso popula um map interno e, a partir daí, todo <Skeleton name="..."> resolve seus bones sozinho.

O que tem dentro do .bones.json

Essa é a parte que costuma ficar ali escondida e na qual você normalmente não toca, mas é bom entender como funciona, e é mais simples do que parece.

Cada skeleton vira um arquivo dentro de bones/, no nosso caso, product-card.bones.json com o nome usado no <Skeleton>, o viewport em que a medição aconteceu, as dimensões do container e a lista de bones:

{
  "name": "product-card",
  "viewportWidth": 375,
  "width": 288,
  "height": 310,
  "bones": [
    [0, 0, 100, 310, 12, true],
    [5.9, 17, 88.2, 144, 8],
    [5.9, 181, 59, 16, 4]
  ]
}

Cada bone é um array: [x, y, w, h, r, isContainer?]. O x e o w vêm em porcentagem da largura do container, o y e o h em pixels, o r é o border radius, e o último elemento, quando está presente, marca aquele bone como container. Um bone com a flag container = true representa um elemento de container e ele é renderizado com um tom mais claro para que os filhos se destaquem.

É isso. Não tem layout engine em runtime, não tem cópia do seu componente: o runtime cria um container position: relative, joga cada bone visível como um filho position: absolute e injeta os keyframes da animação. Por isso o bundle é pequeno e por isso não existe layout shift: as medidas vieram do seu próprio DOM.

A demo abaixo renderiza um card com bones escritos exatamente nesse formato. Clique para alternar entre o skeleton e o conteúdo real:

loading=true

Repare que as bordas não "pulam" e o skeleton acompanha perfeitamente o tamanho do card.

O fluxo completo

// app/layout.tsx
import "./bones/registry";

// components/ProductPage.tsx
import { Skeleton } from "boneyard-js/react";

export function ProductPage() {
  const { data, isLoading } = useProduct();

  return (
    <Skeleton
      name="product-card"
      loading={isLoading}
      animate="shimmer"
      transition={300}
      fixture={
        <ProductCard
          data={{
            title: "Produto exemplo",
            description: "Lorem ipsum",
            price: 100,
          }}
        />
      }
    >
      {data && <ProductCard data={data} />}
    </Skeleton>
  );
}
npx boneyard-js build --watch

O --watch é o modo de hot-reload, então o skeleton acompanha as atualizações do componente enquanto você desenvolve.

Por que vale a pena entender isso

O ganho não é economizar várias divs. É que o skeleton deixa de ser código gerado a mão e necessário manutenção para um gerado por comando e versionado no git.

Eu não sei se essa biblioteca é a melhor solução para uma aplicação grande em produção. Para MVPs e SaaS pequenos resolve, bem a parte chata.

Posts relacionados