Pular para o conteúdo

Design system

Os tokens, os componentes e os padrões que constroem a Two9. Cada exemplo desta página é o componente de produção, renderizado ao vivo.

Ver componentes
Versão
1.0
Tokens
137
Componentes
15
Formatos
CSS, Tailwind e JSON W3C
01

Fundamentos

As regras que valem para tudo: de onde vem cada cor, cada medida e cada movimento.

Princípios

Quatro ideias explicam quase toda decisão visual da Two9. Quando um caso não estiver nesta página, volte a elas.
  1. 01

    O bloco

    O símbolo da Two9 é um bloco amarelo com um 9: uma tecla. Todo elemento interativo nasce dele, com face, borda inferior visível e curso ao ser pressionado.

    • Botões e chips afundam 5px ao clicar.
    • A borda inferior some quando a tecla está pressionada.
    • Superfícies que não são clicáveis não ganham borda de tecla.
  2. 02

    Luz de baixo

    O amarelo não é tinta espalhada. É luz que vaza por baixo do grafite: em pontos de ação, estados ativos e brilhos. Uma seção inteira amarela só existe como momento deliberado.

    • Use o amarelo como luz, em poucos pontos.
    • O brilho nasce na base da tecla, nunca em cima.
    • Amarelo como texto sobre claro usa accent-text.
  3. 03

    Teclas que se apertam sozinhas

    É a metáfora dos agentes de IA: o trabalho acontece sem alguém apertar. As animações digitam, acendem em sequência e mandam ondas pelas teclas.

    • Cada seção tem um movimento principal ligado ao conteúdo.
    • Sequências que digitam valem mais que fade genérico.
    • Com movimento reduzido, mostre o estado final.
  4. 04

    Proporção 14,3%

    O raio do bloco é 52 sobre 363 do lado. Todo elemento quadrado repete essa proporção, de uma tecla de 44px a um painel inteiro.

    • Elementos quadrados usam TILE_RADIUS_RATIO.
    • O resto usa a escala rounded-xs a rounded-tile.
    • Nunca arredonde um quadrado até virar círculo.

Cor

Duas camadas. A paleta crua vira utilitários como bg-charcoal-800. Os papéis semânticos, como bg-surface e text-muted, mudam de valor com o tema. Prefira os papéis: eles fazem o mesmo componente funcionar no site escuro e nas prévias claras.

Paleta

Clique numa cor para copiar o HEX. O contraste com branco e com tinta é calculado a partir dos tokens.

Carvão

O fundo da marca e as teclas grafite.

key-skirt

Saia da tecla

Lateral escura das teclas grafite, abaixo do topo.

Branco
17,4:1
Tinta
1,0:1

charcoal-950

Carvão 950

Borda inferior das teclas e fundo mais profundo.

Branco
18,6:1
Tinta
1,1:1

charcoal-900

Carvão 900

Seções alternadas, poços e tabuleiros.

Branco
17,3:1
Tinta
1,0:1

charcoal-800

Carvão

Fundo da marca. A maior parte de cada tela.

Branco
15,6:1
Tinta
1,0:1

charcoal-700

Grafite

Teclas e superfícies elevadas.

Branco
13,6:1
Tinta
1,1:1

charcoal-600

Carvão 600

Hover de teclas grafite e camadas sobrepostas.

Branco
11,6:1
Tinta
1,4:1

charcoal-500

Carvão 500

Barras de rolagem e detalhes de interface.

Branco
9,0:1
Tinta
1,7:1

Fumaça

Textos sobre carvão e o fundo do tema claro.

smoke-400

Fumaça 400

Texto apagado e placeholders.

Branco
4,4:1
Tinta
3,6:1

smoke-300

Fumaça 300

Texto secundário sobre carvão.

Branco
2,6:1
Tinta
6,1:1

smoke-200

Fumaça 200

Texto corrido sobre carvão.

Branco
1,5:1
Tinta
10,1:1

smoke-100

Fumaça 100

Fundo profundo no tema claro.

Branco
1,1:1
Tinta
13,6:1

Neutros

Papel, branco e a tinta do 9.

paper

Papel

Fundo do tema claro.

Branco
1,0:1
Tinta
15,0:1

white

Branco

Títulos sobre fundo escuro.

Branco
1,0:1
Tinta
16,3:1

ink

Tinta

O 9 do logo e texto sobre amarelo.

Branco
16,3:1
Tinta
1,0:1

Amarelo

A luz, da borda da tecla ao brilho do texto.

yellow-300

Amarelo 300

Brilho do texto em teclas acesas.

Branco
1,1:1
Tinta
13,7:1

yellow-400

Amarelo 400

Hover do amarelo no tema escuro.

Branco
1,3:1
Tinta
12,3:1

yellow-500

Amarelo Two9

A luz. Ação, destaque e o bloco do logo.

Branco
1,4:1
Tinta
11,1:1

yellow-600

Amarelo 600

Hover do amarelo no tema claro.

Branco
1,9:1
Tinta
8,4:1

yellow-700

Amarelo 700

Borda inferior das teclas amarelas.

Branco
3,3:1
Tinta
4,8:1

yellow-800

Amarelo 800

Amarelo como texto sobre fundo claro.

Branco
6,3:1
Tinta
2,5:1

Estados

Sucesso, erro e informação. Sempre com ícone ou texto junto.

success-500

Sucesso

Confirmações e estados concluídos.

Branco
1,8:1
Tinta
8,6:1

danger-500

Erro

Erros de formulário sobre carvão.

Branco
2,8:1
Tinta
5,8:1

danger-on-accent

Erro sobre amarelo

Mensagens de erro sobre superfícies amarelas.

Branco
10,7:1
Tinta
1,5:1

info-500

Informação

Avisos neutros.

Branco
2,4:1
Tinta
6,7:1

Papéis semânticos

Os dois painéis abaixo são renderizados com as variáveis reais de cada tema. O valor ao lado do nome é o token cru que o papel usa.
Tema escuro

Ative com data-theme="dark" em qualquer elemento.

Superfícies

  • Fundo vaziocharcoal-950

    bg-surface-void / --surface-void

  • Fundo afundadocharcoal-900

    bg-surface-sunken / --surface-sunken

  • Fundocharcoal-800

    bg-surface / --surface-base

  • Fundo elevadocharcoal-700

    bg-surface-raised / --surface-raised

  • Sobreposiçãocharcoal-600

    bg-surface-overlay / --surface-overlay

Texto

  • Texto fortewhite

    text-strong / --text-strong

  • Texto corridosmoke-200

    text-body / --text-body

  • Texto secundáriosmoke-300

    text-muted / --text-muted

  • Texto apagadosmoke-400

    text-faint / --text-faint

Linhas

  • Linha sutilrgb(255 255 255 / 0.08)

    border-line / --line-subtle

  • Linha fortergb(255 255 255 / 0.16)

    border-line-strong / --line-strong

Destaque

  • Destaqueyellow-500

    bg-accent / --accent

  • Destaque forteyellow-400

    bg-accent-strong / --accent-strong

  • Destaque como textoyellow-500

    text-accent-text / --accent-text

  • Sobre o destaqueink

    text-on-accent / --on-accent

Foco

  • Anel de focoyellow-500

    :focus-visible / --focus-ring

Tema claro

Ative com data-theme="light" em qualquer elemento.

Superfícies

  • Fundo vaziosmoke-100

    bg-surface-void / --surface-void

  • Fundo afundadosmoke-100

    bg-surface-sunken / --surface-sunken

  • Fundopaper

    bg-surface / --surface-base

  • Fundo elevadowhite

    bg-surface-raised / --surface-raised

  • Sobreposiçãowhite

    bg-surface-overlay / --surface-overlay

Texto

  • Texto forteink

    text-strong / --text-strong

  • Texto corrido#3a3638

    text-body / --text-body

  • Texto secundário#5e5b5c

    text-muted / --text-muted

  • Texto apagado#7a7778

    text-faint / --text-faint

Linhas

  • Linha sutilrgb(35 31 32 / 0.1)

    border-line / --line-subtle

  • Linha fortergb(35 31 32 / 0.2)

    border-line-strong / --line-strong

Destaque

  • Destaqueyellow-500

    bg-accent / --accent

  • Destaque forteyellow-600

    bg-accent-strong / --accent-strong

  • Destaque como textoyellow-800

    text-accent-text / --accent-text

  • Sobre o destaqueink

    text-on-accent / --on-accent

Foco

  • Anel de focoink

    :focus-visible / --focus-ring

Contraste

Calculado em código com a fórmula de luminância relativa da WCAG 2.x e truncado, nunca arredondado para cima. AA pede 4,5:1 para texto e 3:1 para texto grande e partes de interface.
Contraste no tema escuro
Contraste no tema escuro
TextoFundo vazioFundo afundadoFundoFundo elevado
Texto forte
18,6:1AAA
17,3:1AAA
15,6:1AAA
13,6:1AAA
Texto corrido
11,6:1AAA
10,8:1AAA
9,7:1AAA
8,5:1AAA
Texto secundário
6,9:1AA
6,4:1AA
5,8:1AA
5,1:1AA
Texto apagado
4,2:1AA grande
3,9:1AA grande
3,5:1AA grande
3,0:1AA grande
Destaque como texto
12,7:1AAA
11,8:1AAA
10,7:1AAA
9,3:1AAA
Contraste no tema claro
Contraste no tema claro
TextoFundo vazioFundo afundadoFundoFundo elevado
Texto forte
13,6:1AAA
13,6:1AAA
15,0:1AAA
16,3:1AAA
Texto corrido
10,0:1AAA
10,0:1AAA
11,0:1AAA
11,9:1AAA
Texto secundário
5,6:1AA
5,6:1AA
6,2:1AA
6,7:1AA
Texto apagado
3,7:1AA grande
3,7:1AA grande
4,0:1AA grande
4,4:1AA grande
Destaque como texto
5,3:1AA
5,3:1AA
5,8:1AA
6,3:1AA
  • Texto apagado não passa em AA para texto comum. Use só em placeholders, itens desativados e textos de 24px ou mais.
  • No tema claro, amarelo como texto usa text-accent-text, que vira amarelo 800. O amarelo 500 sobre papel fica em 1,3:1.
  • Linhas são decorativas. Campos e teclas vazadas se identificam pelo rótulo e pelo preenchimento, não só pela borda.

Tipografia

Três famílias, cada uma com um trabalho. Unbounded para títulos, Figtree para todo o resto e Martian Mono só para código e registros de agente. Os tamanhos seguem uma escala clássica estendida para display.

Famílias

Unbounded para títulos

font-display pesos 500, 600

Software que trabalha por você.

Peso 600, tracking de -0,03em a -0,045em, entrelinha de 0,95 a 1,1. Ecoa a geometria larga e redonda do TWO.

Figtree para texto e interface

font-sans pesos 400, 500, 600

A Two9 projeta e desenvolve sites, aplicativos, sistemas e agentes de IA para empresas que querem crescer sem multiplicar a equipe.

Corpo em 18px no desktop e 16px no celular, entrelinha 1,6, linhas de até 70 caracteres.

Martian Mono para código e registros

font-mono pesos 400

agenda.reservar({ horario: "10h30" })

Só para código de verdade e registros de agente. Nunca para rótulos decorativos.

Papéis

Receitas prontas. Copie a classe inteira em vez de combinar tamanhos soltos.
  • Título de página

    <h1>

    Design system

    font-display text-[clamp(2.6rem,7.4vw,7.5rem)] leading-[0.98] tracking-[-0.045em]
  • Título de seção

    <h2>

    O que a Two9 constrói

    font-display text-[clamp(2.25rem,5vw,4.5rem)] leading-[1.02] tracking-[-0.04em]
  • Título de bloco

    <h3>

    Agentes de IA

    font-display text-xl lg:text-[1.75rem] tracking-[-0.03em]
  • Introdução

    <p>

    Do agente que atende no WhatsApp ao sistema que organiza a operação.

    text-md sm:text-lg text-body max-w-[34rem]
  • Corpo

    <p>

    Corpo em 18px no desktop e 16px no celular, com entrelinha 1,6 e linhas de até 70 caracteres.

    text-base sm:text-md text-body max-w-[40rem]
  • Rótulo e legenda

    <span>

    Nome completo

    text-sm font-semibold text-strong / text-sm text-muted
  • Código e registros

    <code>

    crm.criar_lead({ origem: "whatsapp" })

    font-mono text-[0.8125rem]

Escala

Cada degrau já traz a entrelinha. text-md é o corpo no desktop.
  1. text-7xl120px, 7,5rem, 0,94Luz de baixo
  2. text-6xl96px, 6rem, 0,96Luz de baixo
  3. text-5xl72px, 4,5rem, 1Luz de baixo
  4. text-4xl60px, 3,75rem, 1,04Luz de baixo
  5. text-3xl48px, 3rem, 1,08Luz de baixo
  6. text-2xl36px, 2,25rem, 1,15Luz de baixo
  7. text-xl24px, 1,5rem, 1,35Luz de baixo
  8. text-lg21px, 1,3125rem, 1,5Luz de baixo
  9. text-md18px, 1,125rem, 1,6Luz de baixo
  10. text-base16px, 1rem, 1,6Luz de baixo
  11. text-sm14px, 0,875rem, 1,55Luz de baixo
  12. text-xs12px, 0,75rem, 1,5Luz de baixo

Espaçamento

A base é 4px, a escala padrão do Tailwind. O número da classe vezes 4 dá a medida: p-6 é 24px e gap-2.5 é 10px. Use só os degraus abaixo.

Escala

  1. 0,52pxAjustes ópticos.
  2. 14pxEntre ícone e texto pequeno.
  3. 1,56pxÍcone de mensagem e texto.
  4. 28pxRótulo e campo.
  5. 2,510pxÍcone e texto em botões, entre chips.
  6. 312pxEntre teclas pequenas.
  7. 416pxPadding de botão pequeno.
  8. 520pxMargem lateral no celular.
  9. 624pxPadding de botão, entre blocos de texto.
  10. 832pxMargem lateral a partir de 640px, padding de botão grande.
  11. 1040pxEntre título e conteúdo.
  12. 1248pxEntre colunas do grid.
  13. 1664pxEntre grupos dentro de uma seção.
  14. 2080pxEntre o cabeçalho de seção e o conteúdo.
  15. 2496pxPadding vertical de seção no celular.
  16. 32128pxPadding vertical de seção a partir de 640px.
  17. 40160pxPadding vertical de seção a partir de 1024px.

Na prática

O botão médio: 48px de altura, 24px de cada lado e 10px entre o texto e o ícone.
Medidas do botão médio

Ritmo de seção

Seções respiram por padding, não por linhas. O padding cresce com a tela.

96px

Celular, py-24

128px

A partir de 640px, sm:py-32

160px

A partir de 1024px, lg:py-40

Raios

Todo canto vem do bloco do logo: 52 de raio para 363 de lado, 14,3%. Elementos quadrados calculam o raio com TILE_RADIUS_RATIO. O resto usa a escala de rounded-xs a rounded-tile.

Escala

Cada amostra mostra o canto em tamanho real, como ele aparece num elemento grande.
  • rounded-xs

    4px, 0,25rem

    Anel de foco, selos.

  • rounded-sm

    8px, 0,5rem

    Botões pequenos e médios, teclas de 56px.

  • rounded-md

    12px, 0,75rem

    Botão grande, chips, campos.

  • rounded-lg

    20px, 1,25rem

    Painéis e prévias.

  • rounded-xl

    28px, 1,75rem

    Teclas de serviço e cartões grandes.

  • rounded-2xl

    40px, 2,5rem

    Painéis de seção, como o contato.

  • rounded-tile

    52px, 3,25rem

    O bloco do logo com 363px de lado.

A proporção do bloco

Num quadrado, o raio acompanha o lado. Uma tecla de 44px e um painel de 363px têm o mesmo desenho, só em tamanhos diferentes.

raio = lado × 52 / 363

Keycap nos quatro tamanhos
9
size="sm"medindo
9
size="md"medindo
9
size="lg"medindo
9
size="xl"medindo

Teste a proporção

Arraste para mudar o lado. O raio é recalculado com a mesma constante que os componentes usam.
Tecla com raio proporcional ao lado
20,6px144px × 52/363 = raio de 20,6px

Elevação

Não há sombras genéricas. Cada sombra desenha uma tecla: brilho na aresta de cima, a borda sólida embaixo e a sombra no fundo. Pressionar troca shadow-key por shadow-key-pressed e desce 5px.

Sombras

As camadas são listadas de cima para baixo, na ordem em que o CSS as pinta.
  • Tecla

    shadow-key

    Teclas grafite em repouso: botão secundário, chips, keycaps.

    1. Brilho na aresta de cima.0 1px 0 0 rgb(255 255 255 / 0.06) inset
    2. Sombra na base da face.0 -2px 0 0 rgb(0 0 0 / 0.35) inset
    3. A borda sólida de 6px sob a tecla.0 6px 0 0 #141213
    4. Sombra projetada no fundo.0 14px 28px -10px rgb(0 0 0 / 0.7)
  • Tecla pressionada

    shadow-key-pressed

    A mesma tecla afundada 5px: a borda some e a sombra encolhe.

    1. Brilho mais fraco.0 1px 0 0 rgb(255 255 255 / 0.04) inset
    2. Sombra na base da face.0 -1px 0 0 rgb(0 0 0 / 0.3) inset
    3. Sobra 1px de borda.0 1px 0 0 #141213
    4. Sombra curta e próxima.0 4px 10px -6px rgb(0 0 0 / 0.7)
  • Tecla amarela

    shadow-key-yellow

    Botão primário e teclas acesas: borda em amarelo 700 e luz amarela por baixo.

    1. Brilho forte na aresta de cima.0 1px 0 0 rgb(255 255 255 / 0.45) inset
    2. Sombra na base da face.0 -2px 0 0 rgb(0 0 0 / 0.18) inset
    3. A borda sólida em amarelo 700.0 6px 0 0 #a08c00
    4. A luz que vaza por baixo.0 18px 40px -12px rgb(242 214 17 / 0.45)
  • Tecla amarela pressionada

    shadow-key-yellow-pressed

    Botão primário durante o clique.

    1. Brilho mais fraco.0 1px 0 0 rgb(255 255 255 / 0.35) inset
    2. Sombra na base da face.0 -1px 0 0 rgb(0 0 0 / 0.15) inset
    3. Sobra 1px de borda.0 1px 0 0 #a08c00
    4. A luz se concentra sob a tecla.0 6px 18px -8px rgb(242 214 17 / 0.5)
  • Brilho

    shadow-glow

    Estados ativos e selecionados sem borda de tecla.

    1. Contorno de 1px em amarelo.0 0 0 1px rgb(242 214 17 / 0.4)
    2. Halo difuso.0 0 32px 4px rgb(242 214 17 / 0.25)

Repouso e pressionado

Clique numa tecla para mantê-la pressionada e comparar os dois estados lado a lado.
Teclas que alternam entre repouso e pressionado

Tecla grafiteshadow-key

Tecla amarelashadow-key-yellow

Tokens

Sombras em src/app/globals.css
--shadow-key: 0 1px 0 0 rgb(255 255 255 / 0.06) inset, 0 -2px 0 0 rgb(0 0 0 / 0.35) inset, 0 6px 0 0 #141213, 0 14px 28px -10px rgb(0 0 0 / 0.7);
--shadow-key-pressed: 0 1px 0 0 rgb(255 255 255 / 0.04) inset, 0 -1px 0 0 rgb(0 0 0 / 0.3) inset, 0 1px 0 0 #141213, 0 4px 10px -6px rgb(0 0 0 / 0.7);
--shadow-key-yellow: 0 1px 0 0 rgb(255 255 255 / 0.45) inset, 0 -2px 0 0 rgb(0 0 0 / 0.18) inset, 0 6px 0 0 #a08c00, 0 18px 40px -12px rgb(242 214 17 / 0.45);
--shadow-key-yellow-pressed: 0 1px 0 0 rgb(255 255 255 / 0.35) inset, 0 -1px 0 0 rgb(0 0 0 / 0.15) inset, 0 1px 0 0 #a08c00, 0 6px 18px -8px rgb(242 214 17 / 0.5);
--shadow-glow: 0 0 0 1px rgb(242 214 17 / 0.4), 0 0 32px 4px rgb(242 214 17 / 0.25);

Movimento

O movimento conta a história das teclas: afundar, acender, digitar. Use GSAP de @/lib/gsap dentro de gsap.matchMedia() com FULL_MOTION_QUERY, e anime só transform, opacity e clip-path.

Curvas

Desenhadas a partir dos tokens. O ponto amarelo percorre a curva e a tecla anda no trilho com a mesma aceleração.

Pressionar

ease-press

Teclas afundando e voltando. Rápida no início, sem rebote.

CSS
cubic-bezier(0.3, 0.7, 0.4, 1)
GSAP
power2.out

Entrada

ease-out-expo

Entradas, revelações e luz que se espalha. Chega rápido e assenta devagar.

CSS
cubic-bezier(0.16, 1, 0.3, 1)
GSAP
expo.out

Transição

ease-in-out-quart

Movimentos de um ponto a outro, como painéis que trocam de lugar.

CSS
cubic-bezier(0.76, 0, 0.24, 1)
GSAP
power3.inOut

Durações

Rápido para teclas, médio para interface, longo só para entradas. Toque cada uma para sentir a diferença.
  1. 150ms

    press

    Uma tecla afundando.

  2. 300ms

    interface

    Hover, troca de cor, ícones.

  3. 500ms

    interface-slow

    Abrir e fechar, linhas de foco.

  4. 800ms

    entrance

    Entrada de blocos de texto.

  5. 1200ms

    entrance-slow

    Entrada de títulos e imagens grandes.

Movimentos da marca

Os três gestos que se repetem pelo site. Cada um roda sozinho quando aparece na tela.
Pressionar: 150ms com ease-press
Com movimento reduzido ativo no sistema, as demonstrações mostram só o estado final.
Acender: a luz nasce embaixo
Com movimento reduzido ativo no sistema, as demonstrações mostram só o estado final.
Onda: teclas em sequência
Clique numa tecla para a onda sair dela.

Regras

  • Cada seção tem um movimento principal ligado ao conteúdo. Fade genérico em tudo não conta.
  • Teclas usam ease-press e 150ms. Entradas usam expo.out entre 0,8s e 1,2s.
  • Movimento ligado à rolagem vai em ScrollTrigger. O Lenis já cuida do scroll suave.
  • Com prefers-reduced-motion, mostre o estado final. Nada some, nada fica pela metade.
  • Conteúdo escondido para uma entrada usa só opacity, para não sair da ordem do Tab.

Esqueleto

Seção com movimento
"use client";

import { useRef } from "react";
import { FULL_MOTION_QUERY, gsap, useGSAP } from "@/lib/gsap";

export function Section() {
  const sectionRef = useRef<HTMLElement>(null);

  useGSAP(
    () => {
      const media = gsap.matchMedia();
      media.add(FULL_MOTION_QUERY, () => {
        gsap.from("[data-key]", {
          y: -22,
          opacity: 0,
          stagger: 0.07,
          ease: "expo.out",
          scrollTrigger: { trigger: sectionRef.current, start: "top 80%", once: true },
        });
      });
    },
    { scope: sectionRef },
  );

  return <section ref={sectionRef}>…</section>;
}

Grid e layout

Composições assimétricas em 12 colunas, alinhadas à esquerda. Nada é centralizado por padrão: centralize só num momento único. O conteúdo vive no Container, com até 1440px e margem de 20px no celular e 32px a partir de 640px.

Container e colunas

A partir de 1024px. As colunas acendem de baixo, como as teclas.
Container com 12 colunas
1
2
3
4
5
6
7
8
9
10
11
12
Margem 20px, 32px a partir de 640pxEspaço entre colunas 32px, gap-8Largura máxima 1440px

Composições

As três divisões que mais aparecem no site.

Título e introdução

Cabeçalho de seção: título em 7 colunas, texto alinhado à base nas últimas 5.

7
5

Tabuleiro de serviços

Teclas de larguras diferentes, como uma fileira de teclado: 7 e 5, depois 4, 3 e 5.

7
5
4
3
5

Conteúdo e apoio

Formulário ou demonstração em 8 colunas, contexto em 4.

8
4

Pontos de quebra

Pontos de quebra do Tailwind usados no site
PrefixoLarguraO que muda
sm:640pxMargem lateral passa de 20px para 32px.
md:768pxGrids de duas colunas.
lg:1024pxGrid de 12 colunas e navegação completa.
xl:1280pxComposições largas e seções fixadas.
2xl:1536pxTelas grandes. O container para em 1440px.

Ritmo entre seções

Seções se separam por fundo e padding, nunca por linhas decorativas.
bg-charcoal-800 hero, marquee, processo
bg-charcoal-900 serviços, agentes, compromissos
bg-charcoal-950 manifesto e momentos de pausa
Seção com Container
import { Container } from "@/components/ui/container";

<section className="bg-charcoal-900 py-24 sm:py-32 lg:py-40">
  <Container className="grid gap-6 lg:grid-cols-12 lg:items-end lg:gap-8">
    <h2 className="lg:col-span-7">…</h2>
    <p className="lg:col-span-5">…</p>
  </Container>
</section>

Iconografia

Um traço só, desenhado na grade de 20px com espessura 1,8 e pontas redondas, como o T e o W do logo. Os ícones vêm de src/components/ui/icons.tsx e herdam a cor do texto.

Conjunto

8 ícones. Passe o cursor numa tecla para pressioná-la.
  • ArrowDownIcon
  • ArrowRightIcon
  • ArrowUpRightIcon
  • CheckIcon
  • CloseIcon
  • DownloadIcon
  • PlusIcon
  • SparkIcon

Construção

Ampliado 12 vezes. A área de 2px em volta fica livre para o traço não encostar na borda.
ArrowRightIcon na grade de 20px
Grade
20 × 20
Traço
1,8
Pontas
redondas

Regras

  • Tamanho padrão de 20px. Em teclas pequenas use size-4, em botões o próprio Button reserva 20px.
  • Cor sempre de currentColor. O amarelo entra pelo estado da tecla, não pelo ícone.
  • Ícones são aria-hidden. Quando o ícone é o único conteúdo, dê um aria-label ao controle.
  • Desenhe novos ícones com os mesmos padrões e adicione-os a icons.tsx. Ícones locais espalhados por seções devem migrar para lá.
  • Seta no fim de um link vem do ícone do Button, nunca de um caractere de seta digitado no texto.
Uso
import { ArrowRightIcon, CloseIcon } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";

<Button href="/#contato" icon={<ArrowRightIcon />}>
  Começar um projeto
</Button>

// Ícones já são aria-hidden. Sem texto visível, o controle precisa de um nome.
<button type="button" aria-label="Fechar menu">
  <CloseIcon />
</button>
02

Componentes

Cada componente de src/components/ui com exemplos ao vivo, props tiradas dos tipos, código para copiar e as regras de acessibilidade.
import { Button, buttonClasses } from "@/components/ui/button";

Button

A ação de uma tela. Primary e secondary são teclas: sobem 1px no hover e afundam 5px ao clicar, enquanto a borda inferior some. Use um primary por área.

Variantes e tamanhos

Button nas três variantes e três tamanhos
primary
secondary
ghost

Estados

A primeira e a última coluna são reais. As do meio repetem as classes que o componente aplica no hover, no clique e no foco.
Estados do primary
  • Repouso

  • Hover simulado

  • Pressionado simulado

  • Foco simulado

  • Desativado

Estados do secondary
  • Repouso

  • Hover simulado

  • Pressionado simulado

  • Foco simulado

  • Desativado

Estados do ghost
  • Repouso

  • Hover simulado

  • Foco simulado

  • Desativado

Anatomia

  1. Face

    Superfície da tecla, com o rótulo em Figtree semibold.
    bg-accent / bg-charcoal-700
  2. Borda inferior

    6px sólidos que somem quando a tecla é pressionada.
    shadow-key-yellow → shadow-key-yellow-pressed
  3. Luz

    No primary, o brilho amarelo que vaza por baixo.
    0 18px 40px -12px rgb(242 214 17 / 0.45)
  4. Ícone

    Opcional, depois do texto, numa caixa de 20px que anda no hover.
    icon

Uso

Ação principal com ícone
import { Button } from "@/components/ui/button";
import { ArrowRightIcon } from "@/components/ui/icons";

<Button href="/#contato" size="lg" icon={<ArrowRightIcon />}>
  Começar um projeto
</Button>
Enviar formulário
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
  event.preventDefault();
  if (mutation.isPending) return;
  mutation.mutate(readForm(event.currentTarget));
};

<form onSubmit={handleSubmit}>
  …
  <Button
    type="submit"
    aria-disabled={mutation.isPending || undefined}
    className="aria-disabled:pointer-events-none aria-disabled:translate-y-[5px] aria-disabled:shadow-key-pressed"
  >
    {mutation.isPending ? "Enviando…" : "Enviar mensagem"}
  </Button>
</form>
Só as classes, para outro elemento
import { buttonClasses } from "@/components/ui/button";

<a href="/brand/two9-brand-kit.zip" download className={buttonClasses({ variant: "secondary" })}>
  Baixar kit da marca
</a>

Props

Props de Button
PropTipoPadrãoDescrição
variant"primary" | "secondary" | "ghost""primary"Primary e secondary são teclas com borda inferior. Ghost é um link sublinhado em amarelo.
size"sm" | "md" | "lg""md"Altura de 40, 48 ou 60px. O ghost só muda o tamanho do texto.
iconReactNodenenhumÍcone depois do texto, numa caixa de 20px que anda 2px no hover. Fica aria-hidden.
hrefstringnenhumCom href, o botão vira next/link e aceita as props do Link. Sem href, é um <button>.
childrenobrigatóriaReactNodenenhumO rótulo. Diga exatamente o que acontece ao clicar.
classNamestringnenhumMesclado com tailwind-merge, então pode sobrescrever largura e margens.
type"button" | "submit" | "reset""button"O padrão é "button" para não enviar formulários por acidente.
disabledbooleanfalseOpacidade 45% e sem eventos de ponteiro. Prefira explicar por que a ação não está disponível. Para um envio em andamento, use aria-disabled.

Sem href, aceita todas as props de <button>. Com href, aceita as props de next/link, como prefetch, scroll e target.

Acessibilidade

  • Um <button> de verdade ou um link de verdade, nunca uma div clicável. Com href vira link; com onClick fica botão.
  • O foco visível vem do estilo global: contorno de 2px na cor de foco com 4px de afastamento.
  • O rótulo diz o que acontece. Evite clique aqui, saiba mais e rótulos iguais para destinos diferentes.
  • Num estado de carregamento, mantenha o botão no lugar e troque o texto. Combine com aria-live se o resultado aparecer longe dele.
  • Durante um envio, use aria-disabled e ignore o envio repetido no onSubmit, nunca disabled: um botão desativado perde o foco, e quem usa teclado volta para o início da página.
  • Contraste calculado: tinta sobre amarelo fica em 11,1:1 e branco sobre grafite em 13,6:1.

Faça e não faça

Faça
  • Um primary por área da tela. O resto é secondary ou ghost.
  • Verbo no infinitivo que descreve o resultado: Enviar mensagem, Baixar SVG.
  • Ícone só quando ajuda a prever o que acontece, como seta ou download.
Não faça
  • Dois primaries lado a lado disputando atenção.
  • Seta digitada no texto do link. Use o prop icon.
  • Botão amarelo sobre o fundo amarelo do contato.
import { Keycap, TILE_RADIUS_RATIO } from "@/components/ui/keycap";

Keycap

A tecla em sua forma mais simples: um quadrado com o canto do logo, uma borda inferior e um rótulo curto. Serve para números de etapa, marcadores e o ícone que acompanha um item. Não é clicável por si só.

Tons e tamanhos

Keycap nos três tons e quatro tamanhos
charcoal
9
9
9
9
yellow
9
9
9
9
outline
9
9
9
9

Estados

pressed é uma prop, então os estados abaixo são todos reais.
Keycap em repouso e pressionado
  • 9

    Grafite

  • 9

    Grafite pressionado

  • 9

    Amarelo

  • 9

    Amarelo pressionado

  • Com ícone

Anatomia

  1. Face

    Quadrado com raio de 14,3% do lado, calculado em linha a partir do tamanho.
    borderRadius: side × TILE_RADIUS_RATIO
  2. Borda inferior

    A sombra da tecla. Some quando pressed é true.
    shadow-key / shadow-key-yellow
  3. Rótulo

    Unbounded semibold, com tamanho que acompanha a tecla.
    text-sm a text-4xl

Uso

Número de etapa
import { Keycap } from "@/components/ui/keycap";

<Keycap size="sm" tone="outline" className="text-sm tabular-nums">
  01
</Keycap>
Tecla que acende quando o item abre
<Keycap size="sm" tone={open ? "yellow" : "charcoal"} pressed={open}>
  <PlusIcon className={open ? "rotate-[135deg]" : undefined} />
</Keycap>
Raio proporcional fora do Keycap
import { TILE_RADIUS_RATIO } from "@/components/ui/keycap";

const style = { width: side, height: side, borderRadius: side * TILE_RADIUS_RATIO };

Props

Props de Keycap
PropTipoPadrãoDescrição
size"sm" | "md" | "lg" | "xl""md"Lado de 44, 64, 96 ou 144px. O raio é sempre 14,3% do lado.
tone"charcoal" | "yellow" | "outline""charcoal"Grafite com borda, amarelo aceso com luz por baixo, ou vazado com contorno.
pressedbooleanfalseAfunda 5px e troca a sombra pela versão pressionada. O vazado afunda 2px.
classNamestringnenhumClasses extras, como tamanho de texto para rótulos longos.
childrenReactNodenenhumUm caractere, um número curto ou um ícone.

Acessibilidade

  • Renderiza um <span> sem papel. Quando é só decoração, envolva com aria-hidden, como o Accordion faz.
  • Quando o rótulo importa, como um número de etapa, ele precisa também existir como texto ao lado ou no nome do controle.
  • Para algo clicável, coloque o Keycap dentro de um <button> ou <a> e dê o nome ao controle.
  • O tom vazado usa text-muted, que passa em AA nos dois temas.

Faça e não faça

010203
Faça
  • Rótulos de um a três caracteres ou um ícone.
  • Use yellow para o estado ativo ou o item que importa.
  • Mantenha os tamanhos da escala para o raio continuar proporcional.
Enviar12
Não faça
  • Palavras inteiras dentro de uma tecla pequena.
  • Várias teclas amarelas na mesma linha.
  • Keycap como botão sem um <button> em volta.
import { LitKey } from "@/components/ui/lit-key";

LitKey

Uma tecla decorativa feita em camadas para ser animada: brilho, borda, face e luz. Pressionar e acender usam só transform e opacidade, por isso é a peça das animações de teclas que se apertam sozinhas.

Luz e estados

Todos reais: lit, light e pressed são props.
LitKey apagada, acesa e pressionada
  • Apagada

  • Acesa, fill

  • Acesa, glow

  • Pressionada

  • Retangular

Anatomia

  1. Brilho

    Mancha amarela desfocada embaixo da tecla. Aparece quando ela acende.
    [data-lit-key-glow]
  2. Borda

    Bloco carvão 950 atrás da face, com a altura de edge.
    edge
  3. Face

    A parte que desce. Leva o rótulo grafite.
    [data-lit-key-cap]
  4. Luz

    Cópia da face com o rótulo aceso. Troca de estado só com opacidade.
    [data-lit-key-light]

Uso

Tecla acesa
import { LitKey } from "@/components/ui/lit-key";

<LitKey side={56} edge={5} lit light="fill" capClassName="font-display text-xl font-semibold">
  9
</LitKey>
Animar com GSAP
const cap = key.querySelector("[data-lit-key-cap]");
const light = key.querySelectorAll("[data-lit-key-light], [data-lit-key-glow]");

gsap
  .timeline()
  .to(cap, { y: 4, duration: 0.07, ease: "power2.in" })
  .to(light, { autoAlpha: 1, duration: 0.08 }, "<")
  .to(cap, { y: 0, duration: 0.42, ease: "back.out(2.6)" });

Props

Props de LitKey
PropTipoPadrãoDescrição
sidenumbernenhumLado em px de uma tecla quadrada. O raio segue a proporção do bloco. Sem side, a tecla se ajusta ao conteúdo.
radiusnumber8Raio em px para teclas que não são quadradas.
edgenumber3Altura da borda inferior em px. Pressionada, a face desce edge − 1.
litbooleanfalseAcende a camada de luz e o brilho por baixo.
light"fill" | "glow""fill"fill pinta a face de amarelo. glow mantém o grafite e deixa a luz vazar de baixo.
pressedbooleanfalseAfunda a face com transform em linha, que o GSAP consegue ler e devolver.
capClassNamestringnenhumClasses da face: tamanho e família da fonte, largura de teclas retangulares.
classNamestringnenhumClasses do contêiner, como margens e alinhamento.
childrenReactNodenenhumO rótulo. É renderizado duas vezes, na face e na camada de luz, para trocar de cor só com opacidade.

Repassa as outras props de <span> ao contêiner, como data-* para animação e style.

Acessibilidade

  • Sempre aria-hidden: o próprio componente marca o contêiner. O significado precisa estar em texto em outro lugar.
  • Não é focável. Para uma tecla clicável, use Button ou coloque a LitKey dentro de um <button> com nome.
  • Animações com LitKey vão dentro de gsap.matchMedia() com FULL_MOTION_QUERY. Com movimento reduzido, mostre o estado final com lit e pressed.

Faça e não faça

Faça
  • Use em sequências animadas e ilustrações de teclado.
  • Defina o estado final com props, para valer sem JavaScript.
  • Leia data-lit-key-edge para saber o curso da tecla.
Não faça
  • Usar LitKey como controle de formulário ou botão.
  • Animar box-shadow ou cor de fundo. Troque a opacidade da camada de luz.
  • Acender todas as teclas ao mesmo tempo sem sequência.
import { KeySequence } from "@/components/ui/key-sequence";

KeySequence

Teclas gigantes que caem, se apertam sozinhas uma depois da outra e deixam uma acesa. Depois a legenda se digita. É o movimento de páginas de um momento só, como a 404.

Exemplo

Clique em Repetir para ver a sequência de novo. Nesta prévia o tamanho das teclas foi reduzido sobrescrevendo --key-side.
KeySequence digitando TWO9

Teclas que se apertam sozinhas.

Passe o cursor pelas teclas ou aperte 9 no teclado.

Anatomia

  1. Fileira

    Um único role=img com o nome dado em label.
    label
  2. Tecla

    Face, base e brilho. O lado vem da variável --key-side.
    [data-key] [data-key-cap] [data-key-glow]
  3. Tecla acesa

    Uma camada amarela que liga quando a tecla é apertada.
    litIndex
  4. Legenda

    Texto digitado, cursor e blocos que entram depois.
    data-typed, data-typed-caret, data-reveal

Uso

Página 404
import { KeySequence } from "@/components/ui/key-sequence";

<KeySequence keys={["4", "0", "4"]} litIndex={1} label="Erro 404">
  <h1 data-typed>
    Esta página não existe.
    <span data-typed-caret aria-hidden className="animate-caret …" />
  </h1>
  <p data-reveal>O endereço mudou ou nunca existiu.</p>
</KeySequence>

Props

Props de KeySequence
PropTipoPadrãoDescrição
keysobrigatóriareadonly string[]nenhumUm caractere por tecla, na ordem em que são digitadas.
labelobrigatóriastringnenhumNome acessível da fileira, que é exposta como uma única imagem.
litIndexnumbernenhumÍndice da tecla que termina acesa em amarelo, como o bloco do logo.
childrenReactNodenenhumLegenda depois das teclas. data-typed aparece letra a letra, data-typed-caret pisca no fim e data-reveal entra por último.
classNamestringnenhumClasses do contêiner externo.

Acessibilidade

  • A fileira é uma imagem com nome. Os caracteres das teclas não são lidos um a um.
  • O texto digitado é dividido com SplitText em modo aria auto, então leitores de tela recebem a frase inteira.
  • As teclas respondem ao cursor e às teclas do teclado com o mesmo caractere, menos quando o foco está num campo de texto.
  • Com movimento reduzido nada se move: as teclas, a tecla acesa e a legenda aparecem prontas.

Faça e não faça

Faça
  • Use uma vez por página, num momento único.
  • Poucas teclas: de duas a quatro.
  • Termine com uma tecla acesa que tenha sentido, como o 0 de 404.
Não faça
  • Usar como título de seção comum.
  • Mais de uma KeySequence na mesma tela.
  • Acender mais de uma tecla.
import { Container } from "@/components/ui/container";

Container

A caixa que alinha todo conteúdo: largura máxima de 1440px, centralizada, com 20px de margem no celular e 32px a partir de 640px. O fundo da seção fica fora dele e vai de ponta a ponta.

Exemplo

A área hachurada é a margem lateral. Redimensione a janela para ver 20px virar 32px.
Container com margens destacadas

Conteúdo da seção

Até 1440px de largura, sempre alinhado à esquerda do grid.

Anatomia

  1. Largura máxima

    1440px, centralizada com margem automática.
    mx-auto max-w-[1440px]
  2. Margem lateral

    20px no celular, 32px a partir de sm.
    px-5 sm:px-8

Uso

Seção padrão
import { Container } from "@/components/ui/container";

<section aria-labelledby="servicos-titulo" className="bg-charcoal-900 py-24 sm:py-32 lg:py-40">
  <Container className="grid gap-6 lg:grid-cols-12 lg:gap-8">…</Container>
</section>

Props

Props de Container
PropTipoPadrãoDescrição
classNamestringnenhumMesclado com o padrão. Use para grid e espaçamento interno, não para mudar a largura máxima.
childrenReactNodenenhumO conteúdo da seção.

Aceita todas as props de <div>.

Acessibilidade

  • É só layout: não muda a ordem de leitura nem cria região. A seção em volta leva o aria-labelledby.
  • Não esconda conteúdo com overflow no Container; use overflow-x-clip na seção.

Faça e não faça

Faça
  • Fundo na seção, conteúdo no Container.
  • Grid de 12 colunas direto no Container.
Não faça
  • Containers aninhados, que somam margens.
  • Mudar max-w para alinhar um bloco; use colunas do grid.
import { ResponsiveImage, type MediaName } from "@/components/ui/responsive-image";

ResponsiveImage

Imagens otimizadas para o site exportado estático: um <picture> com AVIF e WebP em duas larguras. Sem next/image, porque o export não otimiza na hora.

Catálogo

As 12 mídias aceitas por name, cada uma no seu formato.
Todas as mídias de public/media
  • Teclas grafite acesas em amarelo por baixo

    keyfield

  • Bloco amarelo da Two9 em 3D com o 9 em baixo relevo

    symbol3d

  • Cubos grafite em volta de um cubo de vidro amarelo com filamentos de luz

    service-agents

  • Peças grafite caindo como dominó, a última acesa em amarelo

    service-automation

  • Janela de navegador em vidro escuro desenhada com linhas de luz amarela

    service-web

  • Celular grafite cuja tela é feita de teclas, uma acesa em amarelo

    service-apps

  • Placas grafite empilhadas com condutores de luz amarela

    service-platforms

  • Esfera, cubo e cilindro grafite equilibrados, com um cubo amarelo

    service-design

  • Cartões de visita e papel timbrado da Two9

    mockup-stationery

  • Logo da Two9 iluminado na parede de um escritório

    mockup-signage

  • Moletom preto com o logo da Two9

    mockup-hoodie

  • Mão segurando um celular com o ícone do app da Two9

    mockup-phone

Anatomia

  1. Fontes

    AVIF primeiro, WebP depois, cada um com 960w e 1920w.
    /media/<name>-960.avif
  2. Imagem

    WebP de 960 como reserva, cobrindo a caixa.
    object-cover
  3. Carregamento

    Lazy por padrão; eager e prioridade alta com priority.
    priority

Uso

Imagem de serviço
import { ResponsiveImage } from "@/components/ui/responsive-image";

<ResponsiveImage
  name="service-agents"
  alt=""
  sizes="(min-width: 1024px) 34vw, 100vw"
  className="aspect-square"
/>

Props

Props de ResponsiveImage
PropTipoPadrãoDescrição
nameobrigatóriaMediaNamenenhumUma das 12 mídias de public/media. Cada uma existe em 960 e 1920px, AVIF e WebP.
altobrigatóriastringnenhumDescreve a imagem. Use "" quando ela só repete o texto ao lado.
sizesstring"100vw"Quanto da tela a imagem ocupa, para o navegador escolher entre 960 e 1920.
classNamestringnenhumClasses do <picture>.
imageClassNamestringnenhumClasses do <img>, como object-position.
prioritybooleanfalseCarrega já, com fetchpriority alta. Só para a imagem principal acima da dobra.

Acessibilidade

  • alt é obrigatório. Descreva o que a imagem mostra, não o arquivo.
  • Quando o título ao lado já diz tudo, use alt="" e aria-hidden no contêiner, como nas teclas de serviço.
  • Imagens nunca carregam texto essencial; o texto vai no HTML.

Faça e não faça

Faça
  • Passe sizes real para não baixar 1920 num celular.
  • Dê à caixa uma proporção fixa para a página não pular.
Não faça
  • priority em várias imagens da mesma tela.
  • Imagens de serviço com alt repetindo o título.
import { VideoLoop } from "@/components/ui/video-loop";

VideoLoop

Vídeo decorativo em loop, sem som. Só toca enquanto está na tela, pausa ao sair e fica no pôster com movimento reduzido ou com as animações pausadas no site. Carrega o WebM primeiro e cai para MP4, com um corte de 1280px para telas menores.

Loops disponíveis

name="keyfield"
Teclas acendendo em amarelo numa sequência de digitação. 16:9, 5 segundos.
name="symbol"
O bloco amarelo do 9 girando 360°. 16:9, 5 segundos.

Anatomia

  1. Pôster

    Primeiro quadro em WebP numa <img> lazy por baixo do vídeo, eager com priority. Não usa o atributo poster, que o navegador baixa ao abrir a página mesmo com o vídeo longe da tela.
    /media/<name>-loop-poster.webp
  2. Fontes

    WebM VP9 e MP4 H.264, na ordem do mais leve ao mais compatível. Nada baixa antes do vídeo tocar.
    <source type media>, preload="none"
  3. Observador

    Toca e pausa conforme o vídeo entra e sai da tela, com 25% de antecedência.
    IntersectionObserver

Uso

Fundo de uma composição
import { VideoLoop } from "@/components/ui/video-loop";

<div className="relative aspect-video overflow-hidden rounded-xl">
  <VideoLoop name="keyfield" />
</div>

Props

Props de VideoLoop
PropTipoPadrãoDescrição
nameobrigatória"keyfield" | "symbol"nenhumQual loop tocar. Cada um tem WebM VP9, MP4 e um pôster.
classNamestringnenhumClasses do pôster e do <video>, que já cobrem a caixa com object-cover.
prioritybooleanfalseCarrega o pôster eager e com prioridade alta, em vez de lazy. Use só quando o vídeo aparece acima da dobra.

Acessibilidade

  • Sempre muted, playsInline, aria-hidden e fora da ordem do Tab. Nada essencial pode estar só no vídeo.
  • Respeita prefers-reduced-motion: não toca e mostra o pôster.
  • Loops com mais de 5 segundos na tela pedem um controle de pausa pela WCAG 2.2.2. O componente obedece ao MotionToggle do cabeçalho, que pausa todos os vídeos de uma vez.

Faça e não faça

Faça
  • Um vídeo por tela, como momento da marca.
  • Uma caixa com proporção fixa e overflow-hidden.
Não faça
  • Vídeo atrás de texto longo sem um véu escuro.
  • O mesmo loop em duas seções seguidas.
import { Marquee } from "@/components/ui/marquee";

Marquee

Uma fileira em loop contínuo, movida pelo ticker do GSAP. Acelera com a rolagem, troca de sentido quando a pessoa sobe e só roda enquanto está na tela. É decorativa: o mesmo conteúdo precisa existir em texto para leitores de tela.

Exemplo

O botão usa a mesma pausa do cabeçalho, que para todo movimento automático do site. Rolar a página acelera as fileiras, e rolar para cima inverte o sentido.
Duas fileiras em sentidos opostos
  • Next.js
  • React
  • TypeScript
  • Tailwind
  • GSAP
  • Node.js
  • LLMs
  • RAG
  • WhatsApp
  • Postgres
  • Cloudflare
  • Automação

Anatomia

  1. Contêiner

    Corta o conteúdo e é aria-hidden.
    overflow-hidden aria-hidden
  2. Trilho

    Move com transform; volta ao início depois de uma cópia.
    [data-marquee-track]
  3. Cópias

    As cópias do conteúdo, lado a lado.
    copies, copyClassName

Uso

Fileira com lista para leitores de tela
import { Marquee } from "@/components/ui/marquee";
import { setMotionPaused, useMotionPaused } from "@/lib/motion-preference";

const paused = useMotionPaused();

<ul className="sr-only">
  {technologies.map((name) => <li key={name}>{name}</li>)}
</ul>
<Marquee paused={paused} copyClassName="py-2">
  {technologies.map((name) => <span key={name}>{name}</span>)}
</Marquee>
<button type="button" aria-pressed={paused} onClick={() => setMotionPaused(!paused)}>
  Pausar movimento
</button>

Props

Props de Marquee
PropTipoPadrãoDescrição
childrenobrigatóriaReactNodenenhumUma cópia do conteúdo. O componente repete e emenda as cópias.
direction"left" | "right""left"Sentido do loop. Inverte quando a pessoa rola para cima.
pixelsPerSecondnumber32Velocidade base. A velocidade da rolagem soma até 6 vezes isso.
copiesnumber3Quantas cópias ficam lado a lado, no mínimo 2. Aumente se uma cópia for mais estreita que a tela.
pausedbooleanfalseDesacelera até parar, sem tranco.
classNamestringnenhumClasses do contêiner, que corta o que passa da borda.
copyClassNamestringnenhumClasses de cada cópia, como padding vertical para a borda das teclas.

Acessibilidade

  • O Marquee inteiro é aria-hidden. Repita o conteúdo numa lista sr-only ou visível.
  • Ofereça um botão de pausa com aria-pressed. Movimento automático por mais de 5 segundos precisa de um, pela WCAG 2.2.2.
  • Ligue paused à preferência do site, useMotionPaused, para o botão do cabeçalho parar a fileira também.
  • Com movimento reduzido, o trilho fica parado. Mostre o conteúdo como uma lista estática.
  • Nada focável dentro da fileira: links e botões não podem passar correndo.

Faça e não faça

Faça
  • Conteúdo curto e repetível, como nomes de tecnologias.
  • Duas fileiras em sentidos opostos para dar ritmo.
Não faça
  • Links ou botões dentro do Marquee.
  • Informação que só existe na fileira.
import { MotionToggle } from "@/components/ui/motion-toggle";

MotionToggle

O interruptor que para todo movimento automático do site: vídeos em loop, fileiras de tecnologias, teclas que se apertam sozinhas e cursores piscando. Fica no cabeçalho e no menu do celular. A escolha vale para todas as páginas e continua na próxima visita.

Exemplo

São interruptores reais: pausar aqui pausa a página inteira, inclusive o hero e o Marquee acima. O cabeçalho é sempre grafite, então a prévia também.
MotionToggle só com ícone e com rótulo

Anatomia

  1. Tecla

    Grafite com borda inferior. Fica afundada enquanto o movimento está pausado.
    shadow-key → shadow-key-pressed
  2. Ícone

    Duas barras de pausa, que acendem em amarelo quando a pausa está ligada.
    text-yellow-500
  3. Rótulo

    Pausar animações, visível com showLabel ou só para leitores de tela.
    showLabel
  4. Preferência

    Guardada no navegador e espelhada no <html> para os loops em CSS. Os loops em GSAP assinam a mesma preferência.
    data-motion="paused"

Uso

No cabeçalho e no menu do celular
import { MotionToggle } from "@/components/ui/motion-toggle";

<MotionToggle className="hidden lg:inline-flex" />
<MotionToggle showLabel className="mb-4 self-start" />
Um loop novo que obedece à pausa
import { FULL_MOTION_QUERY, gsap } from "@/lib/gsap";
import { pauseWithMotionPreference } from "@/lib/motion-preference";

media.add(FULL_MOTION_QUERY, () => {
  const loop = gsap.timeline({ repeat: -1 });
  …
  return pauseWithMotionPreference(loop);
});

Props

Props de MotionToggle
PropTipoPadrãoDescrição
showLabelbooleanfalseMostra o rótulo ao lado do ícone. Sem ele, o botão é um quadrado de 40px com o nome em sr-only e num title.
classNamestringnenhumClasses do botão, como a visibilidade por breakpoint e as margens.

Acessibilidade

  • O nome é sempre Pausar animações. O estado vai em aria-pressed, e o leitor de tela anuncia se a pausa está ligada.
  • Atende a WCAG 2.2.2: todo movimento automático com mais de 5 segundos para com um clique, em qualquer página.
  • Soma-se a prefers-reduced-motion. Quem pediu movimento reduzido no sistema já não vê os loops; o interruptor serve a quem não pediu.
  • Todo loop novo precisa obedecer: use pauseWithMotionPreference, subscribeMotionPaused ou useMotionPaused de @/lib/motion-preference.

Faça e não faça

Faça
  • Um só à vista: no cabeçalho a partir de lg, no menu abaixo disso.
  • Pausas locais, como a da fileira de tecnologias, mudam a mesma preferência.
Não faça
  • Trocar o rótulo conforme o estado; quem diz o estado é o aria-pressed.
  • Um loop automático que ignora a preferência.
import { Accordion, type AccordionPanel } from "@/components/ui/accordion";

Accordion

Perguntas e respostas que abrem no lugar. O + de cada item é uma tecla: ao abrir, ela afunda, acende em amarelo e gira até virar ×. Segue o padrão de accordion da WAI-ARIA.

Exemplo

O primeiro item começa aberto. Use Tab e as setas para navegar.
Accordion com três perguntas

Depende do escopo. Um site institucional e um sistema com integrações têm tamanhos bem diferentes.

Por isso começamos com uma conversa sobre o que você precisa. Depois dela, você recebe uma proposta com preço fechado, sem surpresa no meio do caminho.

Anatomia

  1. Título

    Um h2, h3 ou h4 que envolve o botão.
    headingLevel
  2. Botão

    A pergunta e a tecla, com aria-expanded e aria-controls.
    [data-accordion-trigger]
  3. Tecla

    Keycap pequeno com +. Aberto, fica amarelo e pressionado.
    [data-accordion-key]
  4. Resposta

    Região rotulada pelo botão. Fechada, fica inert.
    role="region" inert
  5. Linha de luz

    Uma linha amarela que corre pela base do item aberto.
    scale-x-100

Uso

Perguntas frequentes
import { Accordion } from "@/components/ui/accordion";

<Accordion
  headingLevel={3}
  panels={FAQ.map((item) => ({ id: item.id, heading: item.question, body: <p>{item.answer}</p> }))}
  onToggle={(id, open) => open && pressQuestionKey()}
/>

Props

Props de Accordion
PropTipoPadrãoDescrição
panelsobrigatóriareadonly AccordionPanel[]nenhumItens com id, heading e body. O id vira parte dos ids dos controles.
headingLevel2 | 3 | 43Nível do título que envolve cada botão. Siga a hierarquia da página.
defaultOpenIdsreadonly string[][]Itens que começam abertos. Vários podem ficar abertos ao mesmo tempo.
onToggle(id: string, open: boolean) => voidnenhumAvisa quando um item abre ou fecha, para animar algo em volta.
classNamestringnenhumClasses da lista.

Acessibilidade

  • Enter e Espaço abrem e fecham. Setas para cima e para baixo, Home e End movem o foco entre as perguntas.
  • Respostas fechadas ficam inert: não recebem foco nem são lidas.
  • Cada resposta é uma região rotulada. Com mais de seis itens, a WAI-ARIA sugere tirar o papel de região para não poluir a lista de regiões.
  • Escolha headingLevel pela hierarquia: dentro de uma seção com h2, os itens são h3.

Faça e não faça

Faça
  • Perguntas curtas, escritas como a pessoa perguntaria.
  • Respostas de um ou dois parágrafos.
Não faça
  • Esconder informação essencial, como preço ou prazo, dentro de um item fechado.
  • Accordion dentro de accordion.
import { TextField } from "@/components/ui/text-field";

TextField

Campo de uma linha com rótulo, dica e erro já ligados. O campo é um poço de tecla: plano, com uma borda inferior sólida que acende em amarelo quando recebe foco.

Estados

Todos reais. Clique num campo para ver a linha de foco acender.
TextField na superfície charcoal
  • Vazio

  • Preenchido

  • Se for um projeto pessoal, deixe em branco.

    Opcional com dica

  • Digite o número com DDD.

    Erro

  • Desativado

Superfície accent

TextField sobre o painel amarelo

Digite um e-mail válido.

Anatomia

  1. Rótulo

    Figtree semibold 14px, com opcional quando cabe.
    label, optional
  2. Poço

    Fundo translúcido, cantos de cima arredondados e borda inferior de 2px.
    fieldControlClasses(surface)
  3. Linha de foco

    Uma linha de 3px que corre da esquerda quando o campo recebe foco.
    peer-focus:scale-x-100
  4. Dica e erro

    FieldMessage abaixo do campo; o erro ganha ícone e peso.
    hint, error

Uso

Campo de e-mail
import { TextField } from "@/components/ui/text-field";

<TextField
  name="email"
  type="email"
  label="E-mail"
  autoComplete="email"
  inputMode="email"
  required
  error={fieldErrors.email}
/>
Peças para um controle próprio
import { FieldShell, fieldControlClasses, useFieldA11y } from "@/components/ui/text-field";

const field = useFieldA11y({ id, hint, error });

<FieldShell label="Orçamento" hint={hint} error={error} field={field}>
  <select id={field.controlId} aria-describedby={field.describedBy} aria-invalid={field.invalid} className={fieldControlClasses("charcoal", "h-14")}>…</select>
</FieldShell>

Props

Props de TextField
PropTipoPadrãoDescrição
labelobrigatóriastringnenhumRótulo visível, ligado ao campo por htmlFor.
optionalbooleanfalseMostra "opcional" ao lado do rótulo. Campos sem a marca são obrigatórios.
hintstringnenhumDica abaixo do campo, ligada por aria-describedby.
errorstringnenhumMensagem de erro. Liga aria-invalid, pinta a borda e entra no aria-describedby.
surface"charcoal" | "accent""charcoal"charcoal para o site escuro, accent para o painel amarelo do contato. Cada uma tem cores de texto e erro com contraste próprio.
classNamestringnenhumClasses do bloco inteiro: rótulo, campo e mensagens. Use para colunas do grid.
inputClassNamestringnenhumClasses do <input>.
idstringuseId()Gerado quando omitido. Os ids da dica e do erro derivam dele.
typestring"text"Use email, tel e url com o inputMode e o autoComplete certos.

Repassa as outras props de <input>: name, required, autoComplete, inputMode, maxLength, placeholder, defaultValue, disabled.

Acessibilidade

  • Rótulo sempre visível. O placeholder é exemplo, nunca rótulo.
  • Dica e erro entram no aria-describedby, e o erro liga aria-invalid. O leitor de tela lê os dois ao focar.
  • Valide ao enviar, leve o foco ao primeiro campo com erro e limpe o erro quando a pessoa corrigir.
  • O foco não usa o contorno global: a linha de 3px e o fundo mais claro marcam o campo ativo.
  • Na superfície accent, o erro usa #7a1608, que fica em 7,4:1 sobre o amarelo.

Faça e não faça

Faça
  • Rótulos curtos com o nome do dado: Nome, E-mail, Empresa.
  • Marque como opcional o que for opcional e deixe o resto obrigatório.
  • Mensagens de erro que dizem como corrigir.
Não faça
  • Placeholder no lugar do rótulo.
  • Erro só em cor, sem texto.
  • Campo charcoal sobre o painel amarelo.
import { TextArea } from "@/components/ui/text-area";

TextArea

O mesmo poço do TextField para textos longos. Cresce com o conteúdo onde o navegador suporta field-sizing e vira uma caixa redimensionável nos outros.

Estados

TextArea vazio e com erro

Conte um pouco mais. Pelo menos 10 caracteres.

Anatomia

  1. Rótulo, dica e erro

    Os mesmos do TextField, pelo mesmo FieldShell.
    FieldShell
  2. Caixa

    Mínimo de 144px, máximo de 416px, redimensionável na vertical.
    min-h-36 max-h-[26rem] [field-sizing:content]

Uso

Mensagem do contato
import { TextArea } from "@/components/ui/text-area";

<TextArea
  name="message"
  label="Mensagem"
  required
  maxLength={2000}
  placeholder="O que você quer construir, para quem e o que já existe hoje."
  error={fieldErrors.message}
/>

Props

Props de TextArea
PropTipoPadrãoDescrição
labelobrigatóriastringnenhumRótulo visível, ligado ao campo por htmlFor.
optionalbooleanfalseMostra "opcional" ao lado do rótulo. Campos sem a marca são obrigatórios.
hintstringnenhumDica abaixo do campo, ligada por aria-describedby.
errorstringnenhumMensagem de erro. Liga aria-invalid, pinta a borda e entra no aria-describedby.
surface"charcoal" | "accent""charcoal"charcoal para o site escuro, accent para o painel amarelo do contato. Cada uma tem cores de texto e erro com contraste próprio.
classNamestringnenhumClasses do bloco inteiro.
textareaClassNamestringnenhumClasses do <textarea>, como altura mínima.
idstringuseId()Gerado quando omitido.
rowsnumber5Altura inicial. Onde há field-sizing, a caixa cresce com o texto até 416px.

Repassa as outras props de <textarea>, como name, required, maxLength e placeholder.

Acessibilidade

  • As mesmas regras do TextField: rótulo visível, dica e erro no aria-describedby.
  • Se houver limite de caracteres, diga o limite na dica em vez de cortar o texto em silêncio.

Faça e não faça

Faça
  • Placeholder que mostra o que vale a pena contar.
  • Uma coluna inteira do formulário.
Não faça
  • Altura fixa pequena que esconde o texto.
  • Contador que muda a cada tecla anunciado para leitores de tela.
import { ChoiceChip, ChoiceChipGroup } from "@/components/ui/choice-chip";

ChoiceChip

Escolhas múltiplas em forma de tecla. Cada chip é um checkbox de verdade: em repouso fica erguido com um +, marcado afunda, acende e troca o + por um visto.

Estados

Todos reais. Clique nos chips para marcar e desmarcar.
ChoiceChip na superfície charcoal
Do que você precisa?opcional

Marque quantas opções quiser.

Superfície accent e erro

ChoiceChip sobre o painel amarelo, com erro
Do que você precisa?

Escolha pelo menos uma opção.

Anatomia

  1. Grupo

    Um fieldset com legend, dica e erro.
    ChoiceChipGroup
  2. Tecla

    O <label> com borda de tecla. Marcado, desce 3px.
    has-[:checked]:translate-y-[3px]
  3. Checkbox

    Escondido visualmente, mas focável e lido.
    type="checkbox" sr-only
  4. Ícone

    + em repouso, visto quando marcado.
    PlusIcon / CheckIcon

Uso

Do que você precisa?
import { ChoiceChip, ChoiceChipGroup } from "@/components/ui/choice-chip";

<ChoiceChipGroup legend="Do que você precisa?" optional hint="Marque quantas opções quiser." hintId={`${id}-hint`} errorId={`${id}-error`}>
  {CONTACT_NEEDS.map((need) => (
    <ChoiceChip key={need} name="needs" value={need} label={need} />
  ))}
</ChoiceChipGroup>

Props

ChoiceChip

Props de ChoiceChip
PropTipoPadrãoDescrição
labelobrigatóriastringnenhumTexto do chip, que também é o rótulo do checkbox.
surface"charcoal" | "accent""charcoal"charcoal para o site escuro, accent para o painel amarelo do contato. Cada uma tem cores de texto e erro com contraste próprio.
classNamestringnenhumClasses do <label> que desenha a tecla.
checkedbooleannenhumControlado. Para não controlado, use defaultChecked.
disabledbooleanfalseOpacidade 45% e cursor de bloqueio.

Repassa as props de <input type=checkbox>: name, value, defaultChecked, onChange.

ChoiceChipGroup

Props de ChoiceChipGroup
PropTipoPadrãoDescrição
legendobrigatóriastringnenhumA pergunta, num <legend> do <fieldset>.
optionalbooleanfalseMostra "opcional" ao lado da legenda.
hintstringnenhumDica abaixo dos chips.
errorstringnenhumErro do grupo.
errorIdobrigatóriastringnenhumId da mensagem de erro, ligado ao fieldset por aria-describedby.
hintIdobrigatóriastringnenhumId da dica.
surface"charcoal" | "accent""charcoal"charcoal para o site escuro, accent para o painel amarelo do contato. Cada uma tem cores de texto e erro com contraste próprio.
classNamestringnenhumClasses do <fieldset>.
childrenobrigatóriaReactNodenenhumOs ChoiceChip.

Acessibilidade

  • São checkboxes nativos: Tab chega em cada um e Espaço marca ou desmarca.
  • O foco aparece no chip inteiro, com o contorno global, porque o input é invisível.
  • O fieldset e a legend dão o contexto da pergunta para cada chip.
  • Marcado não depende só de cor: a tecla afunda e o ícone muda.

Faça e não faça

Faça
  • Opções curtas, de uma ou duas palavras.
  • Quando a pessoa pode marcar mais de uma.
Não faça
  • Escolha única. Para isso use botões de rádio.
  • Mais de oito opções num grupo.
import { FieldMessage, ACCENT_ERROR_CLASSES } from "@/components/ui/field-message";

FieldMessage

A linha de texto abaixo de um campo: dica ou erro. Os campos já usam por dentro; use direto só em controles próprios.

Tons e superfícies

Superfície charcoal

Marque quantas opções quiser.

Digite um e-mail válido.

Superfície accent

Marque quantas opções quiser.

Digite um e-mail válido.

Anatomia

  1. Ícone

    Só no erro: um círculo com exclamação, em 16px.
    tone=error
  2. Texto

    14px. A dica usa text-muted, o erro a cor de erro da superfície.
    text-sm

Uso

Erro de um controle próprio
import { FieldMessage } from "@/components/ui/field-message";

<FieldMessage id={`${id}-error`} tone="error">
  Escolha uma data a partir de amanhã.
</FieldMessage>

Props

Props de FieldMessage
PropTipoPadrãoDescrição
idobrigatóriastringnenhumReferenciado pelo aria-describedby do campo.
tone"hint" | "error""hint"error adiciona ícone e peso semibold.
surface"charcoal" | "accent""charcoal"charcoal para o site escuro, accent para o painel amarelo do contato. Cada uma tem cores de texto e erro com contraste próprio.
classNamestringnenhumClasses do parágrafo.
childrenobrigatóriaReactNodenenhumA mensagem, numa frase.

Acessibilidade

  • Sempre ligado ao campo por aria-describedby com o mesmo id.
  • Não é uma região viva. Para anunciar erros, leve o foco ao campo.

Faça e não faça

Faça
  • Diga como corrigir: Digite o número com DDD.
  • Uma frase curta.
Não faça
  • Mensagens genéricas, como Campo inválido.
  • Vermelho comum sobre amarelo; use a superfície accent.
03

Padrões

Composições que se repetem pelo site, montadas com os componentes acima. Cada uma aponta para onde está em uso.

Hero

A abertura de cada página: o h1, uma frase de apoio, uma ação principal e um visual de teclas. As letras do título sobem enquanto as teclas se apertam.

Miniatura do padrão de hero

Software que trabalha por você.

Uma frase que diz o que a página entrega, em até três linhas.

Anatomia

  1. Título

    O único h1 da página, em até duas linhas.
    font-display clamp até 7.5rem
  2. Apoio

    Uma frase em text-md ou text-lg, até 34rem.
    max-w-[34rem]
  3. Ações

    Um primary e, se precisar, um ghost.
    Button lg
  4. Visual

    Campo de teclas, vídeo ou cena 3D, decorativo.
    aria-hidden

Receita

Estrutura
<section aria-labelledby="hero-title" className="relative isolate overflow-hidden bg-charcoal-800">
  {/* Visual: teclas, vídeo ou cena, sempre aria-hidden */}
  <Container className="pb-16 pt-32 lg:pb-24">
    <h1 id="hero-title" className="max-w-[13ch] font-display text-[clamp(2.6rem,7.4vw,7.5rem)] leading-[0.98] tracking-[-0.045em]">…</h1>
    <p className="mt-7 max-w-[34rem] text-md sm:text-lg">…</p>
    <div className="mt-10 flex flex-wrap items-center gap-x-6 gap-y-4">
      <Button href="/#contato" size="lg" icon={<ArrowRightIcon />}>Começar um projeto</Button>
      <Button href="/#servicos" variant="ghost" size="lg" icon={<ArrowDownIcon />}>Ver serviços</Button>
    </div>
  </Container>
</section>
  • O h1 entra caractere por caractere; cada letra pode disparar uma tecla.
  • Alinhado à esquerda, com o visual ocupando o lado direito ou o fundo.
  • Com movimento reduzido, título e visual aparecem prontos.

Tecla de serviço

Cada serviço é uma tecla larga num tabuleiro afundado. No hover ou no foco, a face desce 7px e a luz amarela vaza pela borda de baixo. A prévia usa o componente real da página inicial.

ServiceTile real, nos layouts largo e estreito
  • Agentes de IA

    Atendentes, SDRs e assistentes internos que respondem, qualificam e executam tarefas no WhatsApp, no site e nos seus sistemas, 24 horas por dia.

    • WhatsApp
    • LLMs
    • RAG
    • Integrações
    Ver um agente trabalhando
  • Automação com IA

    Fluxos que leem documentos, preenchem planilhas, atualizam o CRM e avisam a equipe. O trabalho repetitivo sai da mão das pessoas.

    • n8n
    • APIs
    • OCR
    • CRM

Anatomia

  1. Base

    A saia escura sob a face e a luz que vaza quando ela desce.
    KeyBase
  2. Face

    rounded-xl, bg-charcoal-800. Desce 7px com ease-press.
    KEY_FACE_CLASSES
  3. Imagem

    Mídia de serviço, decorativa. Clareia e aproxima no hover.
    ResponsiveImage alt=""
  4. Texto

    Título, descrição e tags com LEDs que acendem em sequência.
    ServiceTags
  5. Aro

    O brilho de 1px na aresta de cima, pintado por último.
    KeyRim

Receita

Estrutura
import { KEY_FACE_CLASSES, KEY_SLOT_CLASSES, KeyBase, KeyRim } from "@/components/home/services-tile";

<li className={KEY_SLOT_CLASSES}>
  <KeyBase />
  <article aria-labelledby={titleId} className={cn(KEY_FACE_CLASSES, "bg-charcoal-800")}>
    <ResponsiveImage name="service-agents" alt="" … />
    <h3 id={titleId}>Agentes de IA</h3>
    <p>…</p>
  </article>
  <KeyRim />
</li>
  • Larguras diferentes, como uma fileira de teclado: 7 e 5, depois 4, 3 e 5.
  • Só uma tecla amarela no tabuleiro: a do contato, que fecha a fileira.
  • A tecla inteira reage ao foco do link de dentro, com group-has-[:focus-visible].
  • O título segue o outline de onde a tecla aparece: h3 sob o h2 da seção, ou headingLevel={4} numa prévia sob um h3, como esta.

Formulário

O formulário de contato, num painel amarelo que é o momento deliberado do site. Campos em duas colunas, uma pergunta de múltipla escolha e um único botão grafite que contrasta com o amarelo.

Formulário na superfície accent

Digite um e-mail válido.

Do que você precisa?opcional

Anatomia

  1. Painel

    Amarelo, com borda de tecla e luz embaixo. Só existe um assim no site.
    bg-yellow-500 shadow-key-yellow
  2. Campos

    TextField e TextArea com surface accent.
    surface="accent"
  3. Escolhas

    ChoiceChipGroup para o tipo de projeto.
    ChoiceChipGroup
  4. Envio

    Button secondary: grafite sobre amarelo, afundado enquanto envia.
    variant="secondary"

Receita

Estrutura
<form noValidate onSubmit={handleSubmit} className="grid gap-x-6 gap-y-8 sm:grid-cols-2">
  <TextField name="name" label="Nome" autoComplete="name" required error={errors.name} surface="accent" />
  <TextField name="email" type="email" label="E-mail" autoComplete="email" required error={errors.email} surface="accent" />
  <ChoiceChipGroup legend="Do que você precisa?" optional hintId={hintId} errorId={errorId} surface="accent" className="sm:col-span-2">
    {CONTACT_NEEDS.map((need) => <ChoiceChip key={need} name="needs" value={need} label={need} surface="accent" />)}
  </ChoiceChipGroup>
  <TextArea name="message" label="Mensagem" required error={errors.message} surface="accent" className="sm:col-span-2" />
  <Button type="submit" variant="secondary" size="lg">Enviar mensagem</Button>
</form>
  • Valide no envio com o mesmo schema zod do servidor. Leve o foco ao primeiro campo com erro.
  • Enquanto envia, o botão fica pressionado e diz Enviando. Não troque o layout.
  • No sucesso, troque o formulário por uma confirmação com foco. Na falha, ofereça e-mail com a mensagem pronta.
  • Um campo isca escondido barra robôs sem pedir nada à pessoa.

Título de seção

Título grande à esquerda, introdução curta à direita, as duas alinhadas pela base. As linhas do título sobem por uma máscara quando a seção entra.

Cabeçalho de seção em 7 e 5 colunas

O que a Two9 constrói

Do agente que atende no WhatsApp ao sistema que organiza a operação, a Two9 projeta e desenvolve cada peça com o mesmo time.

Anatomia

  1. Título

    h2 com id para o aria-labelledby da seção.
    lg:col-span-7
  2. Introdução

    Uma ou duas frases, alinhada à base do título.
    lg:col-span-5 lg:items-end

Receita

Estrutura
<Container className="grid gap-6 lg:grid-cols-12 lg:items-end lg:gap-8">
  <h2 id="servicos-titulo" className="font-display text-[clamp(2.25rem,5vw,4.5rem)] leading-[1.02] tracking-[-0.04em] lg:col-span-7">
    O que a Two9 constrói
  </h2>
  <p className="max-w-[34rem] text-base sm:text-md lg:col-span-5 lg:col-start-8 lg:pb-2">…</p>
</Container>
  • Sem rótulo em caixa alta acima do título e sem palavra destacada em outra cor.
  • Frase em caixa normal. O título diz o assunto; a introdução diz por que importa.
  • O h2 é o rótulo da seção: section aria-labelledby aponta para o id dele.
04

Tokens

Os mesmos valores do CSS num arquivo que outras ferramentas leem.

Os 137 tokens também existem em tokens/two9.tokens.json, no formato do W3C Design Tokens Community Group. Serve para levar cores, medidas e sombras para o Figma, para apps nativos ou para qualquer ferramenta que leia o padrão.

  • Paleta crua24
  • Papéis por tema32
  • Famílias3
  • Pesos3
  • Tamanhos de texto12
  • Entrelinhas12
  • Espaçamento17
  • Raios7
  • Proporção do bloco1
  • Sombras5
  • Curvas3
  • Durações5
  • Pontos de quebra5
  • Layout8

Formato

Cores com espaço de cor sRGB e HEX, medidas em rem ou px, sombras em camadas. Papéis de tema apontam para a paleta crua.
tokens/two9.tokens.json (trecho)
{
  "color": {
    "yellow": {
      "500": {
        "$type": "color",
        "$value": {
          "colorSpace": "srgb",
          "components": [
            0.949,
            0.8392,
            0.0667
          ],
          "hex": "#f2d611"
        },
        "$description": "Amarelo Two9. A luz. Ação, destaque e o bloco do logo.",
        "$extensions": {
          "br.com.two9": {
            "cssVariable": "--color-yellow-500"
          }
        }
      }
    }
  },
  "theme": {
    "light": {
      "accent-text": {
        "$type": "color",
        "$value": "{color.yellow.800}",
        "$description": "Destaque como texto. Amarelo usado como texto. No tema claro vira 800.",
        "$extensions": {
          "br.com.two9": {
            "cssVariable": "--accent-text",
            "utility": "text-accent-text"
          }
        }
      }
    }
  },
  "easing": {
    "press": {
      "$type": "cubicBezier",
      "$value": [
        0.3,
        0.7,
        0.4,
        1
      ],
      "$description": "Pressionar. Teclas afundando e voltando. Rápida no início, sem rebote.",
      "$extensions": {
        "br.com.two9": {
          "utility": "ease-press",
          "gsap": "power2.out"
        }
      }
    }
  }
}

Fonte da verdade

  • O navegador lê src/app/globals.css. O código e esta página leem src/design-system/tokens.ts.
  • Um teste compara os dois a cada execução e falha se um token mudar só de um lado.
  • O mesmo teste guarda o JSON como snapshot: rodar com -u regenera o arquivo.
  • O botão de download monta o arquivo no navegador a partir do mesmo catálogo.
Terminal
# Confere globals.css, tokens.ts e o JSON
pnpm exec vitest run src/design-system/tokens.test.ts

# Depois de mudar um token, regenera tokens/two9.tokens.json
pnpm exec vitest run src/design-system/tokens.test.ts -u