Pular para o conteúdo
Zumkai

Animação de texto: SplitText, clip-path e o que o CSS já faz

Metade dos efeitos de texto não precisa quebrar o texto em pedaços. A pergunta que separa as duas famílias, e o que o SplitText resolve que o CSS não resolve.

  • motion design
  • css
Comparação entre animar o bloco de texto inteiro com clip-path e quebrá-lo em linhas com SplitText.
Sumário
  1. A pergunta que separa as duas famílias
  2. Sem quebrar o texto: o que o CSS já entrega
  3. Quando quebrar é inevitável
  4. Quando quebrar é a resposta errada
  5. Os dois problemas que quase ninguém menciona
  6. Acessibilidade: o padrão mudou, e para melhor
  7. propIndex: a ponte entre as duas famílias
  8. Custo e limpeza
  9. Perguntas frequentes
  10. Fontes

Uma pergunta separa quase todo efeito de texto em duas famílias: os pedaços precisam se mover de forma independente?

Se não, o CSS resolve sozinho, com uma linha e sem biblioteca. Se sim, você precisa quebrar o texto em elementos, e aí não existe equivalente nativo. O erro comum é instalar a biblioteca para fazer o que a primeira família já resolvia.

A pergunta que separa as duas famílias

Antes de escolher ferramenta, classifique o efeito.

EfeitoPrecisa quebrar?Como fazer
Revelação de baixo para cima, bloco inteiroNãoclip-path ou transform
Gradiente correndo dentro das letrasNãobackground-clip: text
Texto surgindo por trás de uma máscaraNãoclip-path ou mask-image
Sublinhado que cresce no hoverNãotransform: scaleX
Cada linha entrando com atrasoSimSplitText, tipo lines
Cada palavra com atrasoSimSplitText, tipo words
Cada caractere com atraso ou posição própriaSimSplitText, tipo chars
Letra individual reagindo ao mouseSimSplitText, tipo chars

As quatro primeiras linhas são a maioria do que se pede num site institucional. Elas não precisam de biblioteca nenhuma.

Sem quebrar o texto: o que o CSS já entrega

Revelação com clip-path. A propriedade é Baseline desde janeiro de 2020, e as formas básicas são animáveis com interpolação suave. Um inset() que sai de 100% para 0% dá a revelação clássica:

css
.titulo {
  clip-path: inset(0 0 100% 0);
  transition: clip-path .8s cubic-bezier(.2,.8,.2,1);
}
.titulo.visivel {
  clip-path: inset(0 0 0 0);
}

Duas ressalvas documentadas. Valores em url(), apontando para um <clipPath> de SVG, não são animáveis: só as formas básicas interpolam. E qualquer valor diferente de none cria um novo contexto de empilhamento, o mesmo efeito colateral de uma opacidade diferente de 1.

Gradiente dentro das letras com background-clip: text. Baseline desde julho de 2015. O texto precisa ficar transparente para o fundo aparecer:

css
.gradiente {
  background: linear-gradient(90deg, #4f6420, #c4552f);
  background-clip: text;
  color: transparent;
  text-shadow: none;
}

A MDN é explícita sobre três cuidados aqui, e eles não são teóricos:

  • Confira o contraste entre o fundo e o texto, porque quem tem baixa visão precisa ler.
  • Declare uma background-color de reserva, para o texto não sumir se a imagem de fundo falhar.
  • Use @supports (background-clip: text) e ofereça alternativa acessível onde não houver suporte.

Revelação com mask-image. É a terceira técnica nativa, e a mais flexível das três, porque o gradiente da máscara controla a suavidade da borda. Diferente do clip-path, que corta seco, a máscara permite uma transição esfumaçada:

css
.linha {
  mask-image: linear-gradient(to right, #000 0 0, transparent 0 100%);
  mask-size: 200% 100%;
  mask-position: 100% 0;
  transition: mask-position 1s ease;
}
.linha.visivel { mask-position: 0 0; }

O que anima aqui é a posição da máscara, não a forma. Isso evita o contexto de empilhamento novo que o clip-path cria, e mantém o trabalho em propriedades que o compositor resolve.

Escolha entre as duas assim: clip-path quando a borda do corte é reta e nítida, mask-image quando você quer degradê ou uma forma irregular.

Revelação ligada ao scroll, sem JavaScript. Para o caso mais comum de todos, aparecer conforme entra na tela, existe animation-timeline: view(), que roda na thread do compositor. O detalhamento está em CSS scroll-driven sem JS.

Quando quebrar é inevitável

Não existe, hoje, um jeito em CSS de dividir um parágrafo em linhas, palavras ou caracteres endereçáveis. ::first-line alcança a primeira linha e para por aí. Para atraso escalonado por pedaço, o texto precisa virar elementos, e isso é JavaScript.

O SplitText do GSAP é a ferramenta madura para isso, e desde a mudança de licenciamento ele vem no pacote público. Confirmei lendo o arquivo publicado na versão 3.15.0: o cabeçalho traz a licença "no charge" da GreenSock, a mesma que o post sobre o GSAP gratuito detalha.

O uso mínimo:

js
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);

SplitText.create(".titulo", {
  type: "lines",
  mask: "lines",
  autoSplit: true,
  onSplit(self) {
    return gsap.from(self.lines, {
      yPercent: 100,
      opacity: 0,
      stagger: 0.08,
      duration: 0.9,
      ease: "power3.out",
      onComplete: () => self.revert(),
    });
  },
});

Quatro escolhas nesse bloco merecem explicação.

type aceita chars, words e lines, combinados por vírgula, e o padrão é os três juntos. Pedir os três quando você só anima linhas cria três vezes mais elementos do que o necessário. Peça só o que vai animar.

mask embrulha cada pedaço num elemento extra que recorta o conteúdo. É o que faz a linha "subir de dentro" em vez de deslizar por cima do resto. Sem isso, você teria que montar o overflow: hidden na mão. Ele aceita um tipo só por vez.

onSplit é onde a animação deve nascer, e não é detalhe estético: ele roda de novo a cada nova divisão, o que importa por causa do próximo item.

revert() devolve o innerHTML original. Depois que a animação termina, os elementos extras não servem para nada e continuam pesando no DOM e na árvore de acessibilidade.

Os outros campos que valem conhecer

A configuração vai além dos quatro do exemplo. Estes são os que eu uso de fato:

CampoPadrãoPara que serve
type"chars,words,lines"Quais pedaços criar. Peça só o que vai animar
maskausenteEnvolve cada pedaço num recorte. Um tipo por vez
autoSplitfalseDivide de novo quando a fonte carrega ou a largura muda
aria"auto"aria-label no pai, aria-hidden nos pedaços
linesClassausenteClasse nas linhas. Com "++" no fim, numera sozinho
wordsClassausenteIdem, para palavras
charsClassausenteIdem, para caracteres
propIndexfalseCria variáveis CSS com o índice de cada pedaço
smartWrapfalseEvita quebra dentro da palavra em divisão só por caractere
ignoreausenteElementos descendentes que ficam de fora da divisão
deepSlicetrueSubdivide elemento aninhado que atravessa duas linhas
reduceWhiteSpacetrueColapsa espaços consecutivos em um só
tag"div"Elemento usado como invólucro dos pedaços
wordDelimiterespaçoSeparador de palavra, aceita string ou expressão regular

Dois deles resolvem irritações específicas. O sufixo "++" nas classes, como charsClass: "letra++", gera letra1, letra2 e assim por diante, o que permite mirar um pedaço específico no CSS sem contar filhos. E ignore mantém intacto um elemento dentro do bloco, útil quando o título tem um <span> de marca ou um ícone que não deve virar caractere solto.

Quando quebrar é a resposta errada

Precisar dos pedaços não basta para justificar a divisão. Três situações em que eu recuo mesmo tendo o efeito em mente.

Texto longo de leitura. Um artigo, uma descrição de produto, qualquer bloco que a pessoa vai ler de verdade. Dividir multiplica o DOM e, mais importante, mexe na árvore de acessibilidade de um conteúdo que precisa ser lido, não admirado. Reserve a divisão para título, chamada e número de destaque.

Texto que muda depois de montado. Em componente que re-renderiza, cada atualização precisa reverter e dividir de novo, ou os pedaços ficam dessincronizados do conteúdo. Dá para fazer, e o custo de manutenção raramente compensa um efeito decorativo. Quando o texto vem de estado, prefira animar o bloco.

Texto que precisa ser copiado. Divisão por caractere insere elementos entre as letras. Dependendo de como você monta os invólucros, a seleção com o mouse e o copiar-colar saem com espaçamento estranho. Vale testar selecionando o trecho antes de aprovar, porque é o tipo de defeito que ninguém percebe até um cliente reclamar.

O campo reduceWhiteSpace, ligado por padrão, ajuda no terceiro caso ao colapsar espaços consecutivos, e não resolve sozinho. Teste a seleção.

Os dois problemas que quase ninguém menciona

O primeiro é a fonte que carrega depois. Você divide o texto em linhas, a fonte da web termina de carregar, as métricas mudam, o texto reflui, e a divisão que você fez já não corresponde às linhas que aparecem na tela. O resultado é quebra em lugar errado, às vezes uma linha órfã com uma palavra.

autoSplit: true resolve: ele reverte e divide de novo quando as fontes terminam de carregar ou quando a largura do elemento muda. É por isso que a animação precisa nascer dentro de onSplit, para ser recriada junto. O padrão do campo é false, então é opt-in.

O segundo é como o texto é cortado em caracteres. Dividir uma string por posição quebra emoji, letras com marcas combinantes e escritas não latinas, porque um caractere visível nem sempre é uma unidade de código. Lendo o fonte publicado da 3.15.0, o SplitText instancia Intl.Segmenter quando o navegador oferece, e usa isso para segmentar. Na prática, um emoji composto continua inteiro em vez de virar dois pedaços sem sentido.

Para português isso importa pouco no dia a dia, e importa muito no momento em que alguém coloca um emoji no título do hero.

Acessibilidade: o padrão mudou, e para melhor

A crítica histórica a dividir texto é que a árvore de acessibilidade vira lixo: o leitor de tela passa a anunciar letra por letra, ou palavra solta sem pontuação.

O SplitText trata isso por padrão. O campo aria vem como "auto", o que coloca um aria-label com o texto original no elemento pai e marca os pedaços como aria-hidden. O leitor de tela lê a frase, não os cacos. Os outros valores são "hidden", que só esconde os pedaços, e "none", que não mexe em nada.

Vale saber que o padrão é esse, e vale mais ainda não trocar para "none" sem motivo.

Do lado do movimento, animação de texto é um dos casos onde prefers-reduced-motion pesa: caractere entrando em posição aleatória é exatamente o tipo de deslocamento que incomoda. O caminho é substituir, não desligar:

js
const mm = gsap.matchMedia();

mm.add({
  reduzir: "(prefers-reduced-motion: reduce)",
  normal: "(prefers-reduced-motion: no-preference)",
}, (ctx) => {
  const { reduzir } = ctx.conditions;
  SplitText.create(".titulo", {
    type: reduzir ? "lines" : "chars,words,lines",
    autoSplit: true,
    onSplit(self) {
      return gsap.from(reduzir ? self.lines : self.chars, {
        opacity: 0,
        y: reduzir ? 0 : 40,
        stagger: reduzir ? 0.04 : 0.02,
        duration: reduzir ? 0.3 : 0.8,
      });
    },
  });
});

Com a preferência ligada, o efeito vira um fade por linha, sem deslocamento. A tabela completa de substituição por tipo de efeito está em prefers-reduced-motion.

propIndex: a ponte entre as duas famílias

Um campo pouco usado do SplitText fecha a distância entre quebrar o texto e animar em CSS.

Com propIndex: true, cada pedaço recebe uma variável CSS com o índice dele, como --word: 1, --word: 2 e assim por diante. Você usa o JavaScript só para quebrar, e deixa o CSS cuidar do atraso:

css
.palavra {
  animation: entra .6s both;
  animation-delay: calc(var(--word) * 40ms);
}

@keyframes entra {
  from { opacity: 0; transform: translateY(12px); }
}

O ganho é de arquitetura. O escalonamento vira responsabilidade da folha de estilo, muda com media query sem tocar em JavaScript, e desaparece dentro de um bloco de prefers-reduced-motion sem condicional no código.

Custo e limpeza

Dividir texto multiplica o DOM. Um parágrafo de 40 palavras com type: "chars,words,lines" produz algumas centenas de elementos, cada um com estilo próprio. Três hábitos evitam o problema.

Peça só o tipo que vai animar. type: "lines" para revelação por linha, e nada além disso.

Reverta ao terminar. onComplete: () => self.revert() devolve o innerHTML original e limpa tudo, como no exemplo do começo.

Divida só o que está visível. Um ScrollTrigger que dispara a criação perto da viewport evita quebrar dez blocos de texto no carregamento. Os padrões estão em ScrollTrigger: os 8 padrões.

Para a decisão anterior a tudo isso, se o projeto deve carregar GSAP, a árvore de decisão entre GSAP, Motion e CSS puro resolve em três perguntas. E o contexto maior está no guia completo de motion design para web.

Perguntas frequentes

Dá para dividir texto em linhas só com CSS?

Não. O CSS alcança a primeira linha com ::first-line e não oferece um jeito de endereçar linha por linha, palavra por palavra ou caractere por caractere. Para atraso escalonado por pedaço, o texto precisa virar elementos, e isso continua sendo trabalho de JavaScript.

O SplitText é pago?

Não. Ele vem no pacote público do GSAP sob a licença "no charge" da GreenSock, confirmável no cabeçalho do arquivo publicado. A licença tem cláusulas próprias e não é open source, o que está detalhado em GSAP 100% grátis: o que mudou.

Por que minha divisão em linhas quebra errado?

Quase sempre é a fonte da web terminando de carregar depois da divisão, o que muda as métricas e reflui o texto. autoSplit: true reverte e divide de novo quando as fontes carregam ou a largura muda. Como ele recria os elementos, a animação precisa ser criada dentro do callback onSplit para ser recriada junto.

Texto dividido atrapalha leitor de tela?

Por padrão, não. O campo aria vem como "auto", que põe um aria-label com o texto original no elemento pai e marca os pedaços como aria-hidden, de modo que o leitor anuncia a frase inteira. O cuidado é não mudar esse valor para "none" sem ter um motivo e uma alternativa.

Fontes

Verificado em 20 de agosto de 2026.

Gatilho de reavaliação: revisar quando (a) o CSS ganhar um mecanismo nativo de segmentação de texto endereçável, (b) o padrão do campo aria do SplitText mudar, ou (c) background-clip: text deixar de exigir color: transparent.

Leia também