10 — Landing Page + Design System
🇧🇷 Landing Page com Design System
🇬🇧 Landing Page with Design System
Fazer uma landing page é fácil. Qualquer um com HTML básico faz uma landing page. Fazer uma landing page com um design system que não quebra quando alguém muda a cor primária — isso é mais difícil.
Eu já vi o cenário: o designer chega e fala "muda o tom de violeta pra tal tom de azul". Aí você vai lá e descobre que o violeta estava hardcoded em 47 lugares diferentes. Button.tsx, Card.tsx, Hero.tsx, CTA.tsx — cada um com sua própria cor. Você passa a tarde inteira caçando #8b5cf6 e substituindo por #2563eb. E no dia seguinte o designer volta: "na verdade, era outro azul".
Design system resolve isso. Você define as cores uma vez, num lugar só. Os componentes usam variáveis. Quando muda, muda em tudo.
Esse desafio é sobre isso: componentes que se vestem sozinhos, documentados no Storybook, com acessibilidade de verdade.
A estrutura
landing-page/
├── src/
│ ├── app/
│ │ ├── page.tsx # Home
│ │ └── layout.tsx # Layout global (Header + Footer)
│ ├── components/
│ │ ├── ui/ # Design system
│ │ │ ├── Button.tsx
│ │ │ ├── Card.tsx
│ │ │ ├── Dialog.tsx
│ │ │ └── Tooltip.tsx
│ │ └── sections/ # Seções da página
│ │ ├── Hero.tsx
│ │ ├── Features.tsx
│ │ └── CTA.tsx
│ └── lib/
│ ├── tokens.ts # Cores, tipografia, spacing
│ └── cn.ts # classname merge
├── .storybook/
└── tailwind.config.tsEssa estrutura separa o design system (ui/) das seções de negócio (sections/). O motivo? Você pode reutilizar o ui/ em outro projeto. O sections/ é específico dessa landing page. Quando o designer pedir "faz uma página nova", você reusa os componentes de UI e só cria novas seções.
O que faz diferença
Design tokens que funcionam
// lib/tokens.ts
export const tokens = {
colors: {
primary: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
},
neutral: {
50: '#fafafa',
100: '#f5f5f5',
800: '#262626',
900: '#171717',
},
},
spacing: {
0: '0px',
1: '4px',
2: '8px',
3: '12px',
4: '16px',
5: '20px',
6: '24px',
8: '32px',
10: '40px',
12: '48px',
16: '64px',
},
} as const;Ter tokens tipados é ótimo, mas o problema é que você ainda precisa fazer o Tailwind enxergar esses tokens. O truque é sincronizar com o tailwind.config.ts:
// tailwind.config.ts
import { tokens } from './src/lib/tokens';
export default {
theme: {
extend: {
colors: {
brand: tokens.colors.primary,
neutral: tokens.colors.neutral,
},
spacing: tokens.spacing,
},
},
plugins: [],
};Agora você pode usar bg-brand-500 ou text-neutral-800 no Tailwind, e se mudar o token, muda tudo. Mas ainda tem um problema: e se o designer quiser mudar a paleta inteira? Você precisa de um sistema mais flexível.
Tema dinâmico com CSS variables
// lib/theme.ts
export type Theme = 'light' | 'dark' | 'purple' | 'green';
export const themes: Record<Theme, Record<string, string>> = {
light: {
'--color-bg': '#ffffff',
'--color-bg-secondary': '#f5f5f5',
'--color-text': '#171717',
'--color-text-secondary': '#737373',
'--color-primary': '#8b5cf6',
'--color-primary-hover': '#7c3aed',
'--color-border': '#e5e5e5',
},
dark: {
'--color-bg': '#171717',
'--color-bg-secondary': '#262626',
'--color-text': '#fafafa',
'--color-text-secondary': '#a3a3a3',
'--color-primary': '#a78bfa',
'--color-primary-hover': '#c4b5fd',
'--color-border': '#404040',
},
purple: {
'--color-bg': '#faf5ff',
'--color-bg-secondary': '#f3e8ff',
'--color-text': '#3b0764',
'--color-text-secondary': '#7e22ce',
'--color-primary': '#9333ea',
'--color-primary-hover': '#7e22ce',
'--color-border': '#e9d5ff',
},
green: {
'--color-bg': '#f0fdf4',
'--color-bg-secondary': '#dcfce7',
'--color-text': '#14532d',
'--color-text-secondary': '#16a34a',
'--color-primary': '#22c55e',
'--color-primary-hover': '#16a34a',
'--color-border': '#bbf7d0',
},
};
export function applyTheme(theme: Theme) {
const vars = themes[theme] || themes.light;
const root = document.documentElement;
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value);
}
}
// Hook pra usar o tema
import { useState, useCallback, useEffect } from 'react';
export function useTheme(defaultTheme: Theme = 'light') {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('theme') as Theme) || defaultTheme;
});
const changeTheme = useCallback((newTheme: Theme) => {
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
applyTheme(newTheme);
}, []);
useEffect(() => {
applyTheme(theme);
}, [theme]);
return { theme, changeTheme };
}CSS variables são a melhor forma de fazer tema dinâmico. O navegador aplica as variáveis instantaneamente sem re-renderizar a página. Compara com Context API + styled-components que força re-render de toda a árvore — CSS variable é muito mais performático.
Button com CVA (Class Variance Authority)
// components/ui/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/cn';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-2 focus-visible:outline-blue-600 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-violet-600 text-white hover:bg-violet-700',
secondary: 'bg-zinc-100 text-zinc-900 hover:bg-zinc-200',
outline: 'border border-zinc-300 hover:bg-zinc-50',
ghost: 'text-zinc-600 hover:bg-zinc-100',
},
size: {
sm: 'h-9 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ variant, size, className, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}O CVA é um dos packages que mais facilitam minha vida. Você define as variantes uma vez e o TypeScript valida se você passou uma variante que existe. Se o designer criar uma nova variante (ex: danger), você adiciona no cva e pronto — todos os botões ganham a nova variante.
Button com loading e ícone
import { Loader2 } from 'lucide-react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
export function Button({
variant, size, className,
isLoading, leftIcon, rightIcon,
children, disabled, ...props
}: ButtonProps) {
return (
<button
className={cn(
buttonVariants({ variant, size }),
isLoading && 'cursor-wait',
className
)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : leftIcon ? (
<span className="mr-2">{leftIcon}</span>
) : null}
{children}
{rightIcon && !isLoading && (
<span className="ml-2">{rightIcon}</span>
)}
</button>
);
}Card component
// components/ui/Card.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/cn';
const cardVariants = cva(
'rounded-xl border transition-shadow',
{
variants: {
variant: {
default: 'bg-white border-zinc-200 shadow-sm hover:shadow-md',
elevated: 'bg-white border-transparent shadow-md hover:shadow-lg',
outlined: 'bg-transparent border-zinc-300 hover:border-zinc-400',
ghost: 'bg-transparent border-transparent hover:bg-zinc-50',
},
padding: {
none: 'p-0',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
},
},
defaultVariants: {
variant: 'default',
padding: 'md',
},
}
);
interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {}
export function Card({ variant, padding, className, children, ...props }: CardProps) {
return (
<div className={cn(cardVariants({ variant, padding }), className)} {...props}>
{children}
</div>
);
}
export function CardHeader({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn('mb-4', className)} {...props}>
{children}
</div>
);
}
export function CardTitle({ className, children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3 className={cn('text-lg font-semibold text-zinc-900', className)} {...props}>
{children}
</h3>
);
}
export function CardDescription({ className, children, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn('text-sm text-zinc-500', className)} {...props}>
{children}
</p>
);
}Dialog com Radix (acessível de verdade)
// components/ui/Dialog.tsx
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
export function Dialog({ children, ...props }: DialogPrimitive.DialogProps) {
return <DialogPrimitive.Root {...props}>{children}</DialogPrimitive.Root>;
}
export function DialogContent({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in" />
<DialogPrimitive.Content className={cn(
'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-xl',
'w-full max-w-md',
className
)}>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100">
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
export function DialogHeader({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={cn('mb-4', className)}>
{children}
</div>
);
}
export function DialogTitle({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<DialogPrimitive.Title className={cn('text-lg font-semibold', className)}>
{children}
</DialogPrimitive.Title>
);
}
export function DialogTrigger({ children, ...props }: DialogPrimitive.DialogTriggerProps) {
return <DialogPrimitive.Trigger asChild {...props}>{children}</DialogPrimitive.Trigger>;
}Tooltip
// components/ui/Tooltip.tsx
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/cn';
export function TooltipProvider({ children }: { children: React.ReactNode }) {
return <TooltipPrimitive.Provider delayDuration={300}>{children}</TooltipPrimitive.Provider>;
}
export function Tooltip({ children, content, side = 'top' }: {
children: React.ReactNode;
content: string;
side?: 'top' | 'bottom' | 'left' | 'right';
}) {
return (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>
{children}
</TooltipPrimitive.Trigger>
<TooltipPrimitive.Content
side={side}
className={cn(
'z-50 rounded-md bg-zinc-900 px-3 py-1.5 text-xs text-white shadow-md',
'animate-in fade-in-0 zoom-in-95'
)}
>
{content}
<TooltipPrimitive.Arrow className="fill-zinc-900" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Root>
);
}Seções da landing page
Hero
// components/sections/Hero.tsx
import { Button } from '@/components/ui/Button';
import { ArrowRight, Play } from 'lucide-react';
export function Hero() {
return (
<section className="relative overflow-hidden bg-gradient-to-b from-violet-50 to-white px-6 py-24 sm:py-32">
<div className="mx-auto max-w-6xl">
<div className="text-center">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-violet-200 bg-violet-50 px-4 py-1.5 text-sm text-violet-700">
<span className="h-2 w-2 rounded-full bg-violet-500 animate-pulse" />
Novo: Open Finance Brasil compatível
</div>
<h1 className="text-4xl font-bold tracking-tight text-zinc-900 sm:text-6xl">
Banking Stack
<span className="block text-violet-600">para desenvolvedores</span>
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg text-zinc-600">
A plataforma completa para construir sistemas financeiros no Brasil.
Pix, boletos, Open Finance, NFS-e — tudo que você precisa, num lugar só.
</p>
<div className="mt-10 flex items-center justify-center gap-4">
<Button size="lg" variant="primary" rightIcon={<ArrowRight className="h-4 w-4" />}>
Começar agora
</Button>
<Button size="lg" variant="outline" leftIcon={<Play className="h-4 w-4" />}>
Ver demo
</Button>
</div>
</div>
<div className="mt-16 rounded-2xl border border-zinc-200 bg-white p-2 shadow-xl">
<div className="aspect-video rounded-xl bg-gradient-to-br from-violet-100 to-zinc-100 flex items-center justify-center">
<span className="text-zinc-400">Dashboard Preview</span>
</div>
</div>
</div>
</section>
);
}Features
// components/sections/Features.tsx
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/Card';
import {
Zap, Shield, BarChart3, Globe,
type LucideIcon
} from 'lucide-react';
interface Feature {
icon: LucideIcon;
title: string;
description: string;
color: string;
}
const features: Feature[] = [
{
icon: Zap,
title: 'Performance',
description: 'Processamento em tempo real com streaming. Consultas otimizadas com índices e cache distribuído.',
color: 'text-violet-600',
},
{
icon: Shield,
title: 'Segurança',
description: 'OAuth 2.0 FAPI, certificados digitais, criptografia de ponta a ponta. Compliance com LGPD e BACEN.',
color: 'text-emerald-600',
},
{
icon: BarChart3,
title: 'Relatórios',
description: 'CSV, PDF, dashboards em tempo real. Streaming de milhões de registros sem sobrecarregar o servidor.',
color: 'text-blue-600',
},
{
icon: Globe,
title: 'Open Finance',
description: 'Simulador completo do Open Finance Brasil. Consentimento, OAuth, dados de contas e transações.',
color: 'text-amber-600',
},
];
export function Features() {
return (
<section className="px-6 py-24">
<div className="mx-auto max-w-6xl">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-zinc-900 sm:text-4xl">
Tudo que você precisa pra construir
</h2>
<p className="mt-4 text-lg text-zinc-600">
Da autenticação ao relatório financeiro, cada componente é pensado pro cenário brasileiro.
</p>
</div>
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
{features.map((feature) => (
<Card key={feature.title} variant="elevated">
<CardHeader>
<feature.icon className={cn('h-10 w-10 mb-2', feature.color)} />
<CardTitle>{feature.title}</CardTitle>
<CardDescription>{feature.description}</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
</section>
);
}CTA (Call to Action)
// components/sections/CTA.tsx
import { Button } from '@/components/ui/Button';
import { ArrowRight, Github } from 'lucide-react';
export function CTA() {
return (
<section className="bg-zinc-900 px-6 py-24">
<div className="mx-auto max-w-4xl text-center">
<h2 className="text-3xl font-bold text-white sm:text-4xl">
Pronto pra começar?
</h2>
<p className="mt-4 text-lg text-zinc-400">
Banking Stack é open source. Clone o repositório, rode local, e comece a construir.
</p>
<div className="mt-10 flex items-center justify-center gap-4">
<Button size="lg" variant="primary" rightIcon={<ArrowRight className="h-4 w-4" />}>
Ver documentação
</Button>
<Button size="lg" variant="outline" className="border-zinc-600 text-white hover:bg-zinc-800" leftIcon={<Github className="h-4 w-4" />}>
GitHub
</Button>
</div>
</div>
</section>
);
}Layout global
// app/layout.tsx
import { TooltipProvider } from '@/components/ui/Tooltip';
import { Header } from '@/components/sections/Header'; // You'd build this too
import { Footer } from '@/components/sections/Footer';
import { cn } from '@/lib/cn';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Banking Stack - Plataforma Financeira para Devs',
description: 'Construa sistemas financeiros no Brasil com Pix, boletos, Open Finance e NFS-e.',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR">
<body className={cn(inter.className, 'min-h-screen bg-white text-zinc-900 antialiased')}>
<TooltipProvider>
<Header />
<main>{children}</main>
<Footer />
</TooltipProvider>
</body>
</html>
);
}O layout global com TooltipProvider é um detalhe que faz diferença. O Radix Tooltip precisa de um provider no topo da árvore. Colocar em cada página é repetitivo. Colocar no layout resolve de uma vez.
Storybook
Cada componente tem sua história:
// Button.stories.tsx
import { Button } from './Button';
export default {
title: 'UI/Button',
component: Button,
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'outline', 'ghost'] },
size: { control: 'select', options: ['sm', 'md', 'lg'] },
},
};
export const Primary = { args: { children: 'Clicar', variant: 'primary' } };
export const Secondary = { args: { children: 'Cancelar', variant: 'secondary' } };
export const Disabled = { args: { children: 'Não pode', disabled: true } };Roda com pnpm storybook em localhost:6006.
Stories mais ricas
// Card.stories.tsx
import { Card, CardHeader, CardTitle, CardDescription } from './Card';
export default {
title: 'UI/Card',
component: Card,
argTypes: {
variant: { control: 'select', options: ['default', 'elevated', 'outlined', 'ghost'] },
padding: { control: 'select', options: ['none', 'sm', 'md', 'lg'] },
},
};
export const Default = {
args: {
children: (
<CardHeader>
<CardTitle>Título do Card</CardTitle>
<CardDescription>Descrição do card com informações adicionais</CardDescription>
</CardHeader>
),
},
};
export const Elevated = {
args: {
variant: 'elevated',
children: (
<CardHeader>
<CardTitle>Card Elevado</CardTitle>
<CardDescription>Usado em destaque na página</CardDescription>
</CardHeader>
),
},
};
export const WithCustomClass = {
args: {
variant: 'outlined',
className: 'max-w-sm',
children: (
<CardHeader>
<CardTitle>Card Limitado</CardTitle>
<CardDescription>Com largura máxima controlada</CardDescription>
</CardHeader>
),
},
};// Dialog.stories.tsx
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './Dialog';
import { Button } from './Button';
export default {
title: 'UI/Dialog',
component: Dialog,
};
export const Simple = {
render: () => (
<Dialog>
<DialogTrigger>
<Button variant="primary">Abrir Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirmação</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-600">Tem certeza que deseja continuar?</p>
<div className="mt-6 flex justify-end gap-3">
<Button variant="outline">Cancelar</Button>
<Button variant="primary">Confirmar</Button>
</div>
</DialogContent>
</Dialog>
),
};Configuração do Storybook
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/nextjs';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
],
framework: '@storybook/nextjs',
staticDirs: ['../public'],
};
export default config;O addon-a11y é o mais subestimado. Ele escaneia cada componente e aponta problemas de acessibilidade: contraste de cor, labels de ARIA, foco visível. Se você passar o addon-a11y, sua landing page já sai mais acessível que 90% das páginas por aí.
// .storybook/preview.ts
import type { Preview } from '@storybook/react';
import '../src/app/globals.css';
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#171717' },
],
},
},
};
export default preview;Testes com Vitest + Testing Library
// Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Clique aqui</Button>);
expect(screen.getByRole('button', { name: /clique aqui/i })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Clique</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick when disabled', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick} disabled>Clique</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
it('shows loading spinner', () => {
render(<Button isLoading>Carregando</Button>);
expect(screen.getByRole('button')).toBeDisabled();
// O Loader2 do lucide-react renderiza um SVG
expect(document.querySelector('svg')).toBeInTheDocument();
});
it('applies variant classes', () => {
const { rerender } = render(<Button variant="primary">Botão</Button>);
const button = screen.getByRole('button');
expect(button.className).toContain('bg-violet-600');
rerender(<Button variant="outline">Botão</Button>);
expect(button.className).toContain('border');
});
});// Card.test.tsx
import { render, screen } from '@testing-library/react';
import { Card, CardHeader, CardTitle, CardDescription } from './Card';
describe('Card', () => {
it('renders children', () => {
render(
<Card>
<CardHeader>
<CardTitle>Título</CardTitle>
<CardDescription>Descrição</CardDescription>
</CardHeader>
</Card>
);
expect(screen.getByText('Título')).toBeInTheDocument();
expect(screen.getByText('Descrição')).toBeInTheDocument();
});
it('applies variant classes', () => {
const { rerender } = render(<Card variant="elevated">Conteúdo</Card>);
const card = screen.getByText('Conteúdo').parentElement!;
expect(card.className).toContain('shadow-md');
rerender(<Card variant="outlined">Conteúdo</Card>);
expect(card.className).toContain('border-zinc-300');
});
});Rodando: pnpm --filter @banking/landing-page test ou pnpm vitest.
Otimizações Next.js
Imagens com next/image
import Image from 'next/image';
export function HeroImage() {
return (
<Image
src="/images/dashboard-preview.png"
alt="Preview do dashboard do Banking Stack"
width={1200}
height={675}
priority // Carrega antes do resto (acima da dobra)
className="rounded-xl"
/>
);
}Sempre use next/image em vez de <img> no Next.js. Ele faz otimização automática: WebP, lazy loading, responsive sizes. O priority na Hero image garante que ela carrega sem delay — é a primeira coisa que o usuário vê.
Fonte com next/font
// No layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});O display: 'swap' mostra texto com fallback até a fonte carregar. Sem FOIT (Flash of Invisible Text). O variable permite usar a fonte via CSS variable em qualquer lugar.
Metadata para SEO
// app/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Banking Stack - Plataforma Financeira para Desenvolvedores',
description: 'Construa sistemas financeiros no Brasil. Pix, boletos, Open Finance, NFS-e e mais.',
openGraph: {
title: 'Banking Stack',
description: 'Plataforma financeira open source para devs brasileiros',
type: 'website',
locale: 'pt_BR',
},
};Por que essa estrutura funciona
Tailwind + CVA — Você não precisa escrever CSS novo pra cada botão. As variantes são tipadas. O TypeScript te guia: se você escrever
variant="primery", o compilador reclama.Radix UI — Acessibilidade sem pensar. Foco, teclado, ARIA attributes. Tudo pronto. Você não precisa lembrar de colocar
role="dialog",aria-modal, focus trap — o Radix faz.Storybook — O dev de backend consegue ver os componentes sem rodar o app. O designer consegue revisar sem saber React. E o addon-a11y escaneia acessibilidade automaticamente.
cn() helper — Evita conflito de classes do Tailwind quando você junta classes de props com classes fixas. O
twMergeresolve conflitos de forma inteligente: se você passarclassName="bg-red-500"e o componente tiverbg-blue-500, otwMergemantém a classe da prop.Componentes puros — Cada componente UI não tem estado de negócio. Não sabe de API, de formulário, de nada. É puramente visual. Isso permite reuso em qualquer projeto.
Seções separadas — O Hero, Features, CTA são específicos dessa landing page. Se você criar outra página, cria novas seções sem mexer nos componentes UI.
// lib/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}Como rodar
# Landing page
pnpm --filter @banking/landing-page dev
# http://localhost:3000
# Storybook
pnpm --filter @banking/landing-page storybook
# http://localhost:6006
# Testes
pnpm --filter @banking/landing-page test
# Vitest com coverageLições aprendidas
Token primeiro, componente depois — Defina cores, espaçamento, tipografia antes de criar qualquer componente. Se você criar o componente primeiro, vai hardcodar valores e depois terá que refatorar.
CVA é mais que variantes de botão — Dá pra usar em qualquer componente que tenha variações visuais. Card, Badge, Input, Alert. Tudo com variantes tipadas.
Radix resolve problemas que você nem sabia que tinha — Focus trap em dialog, gerenciamento de teclado em dropdown, colisão de z-index em tooltip. Coisas que você faria na mão e provavelmente faria errado.
Storybook não é só pra documentar — É também pra testar visualmente. O addon-interactions permite testar cliques, hover, foco. O addon-a11y escaneia problemas de acessibilidade.
CSS variables > Context API para tema — Mudar uma variável CSS não causa re-render. Mudar um contexto React causa re-render de toda a árvore. Pra troca de tema, CSS variable é muito mais performático.
Teste de componente não testa o Radix — Teste seu componente, não a biblioteca. Se você testar que o Dialog abre, está testando o Radix. Teste que seu botão chama
onClick, que o loading mostra spinner, que o disabled desabilita.Acessibilidade não é opcional — Contraste, foco visível, labels, ARIA. O addon-a11y no Storybook te ajuda a não esquecer. A LGPD não exige acessibilidade, mas o bom senso sim.