Layout
Shell completo + primitivos de espaçamento + utilitários responsivos.
O que é esta categoria
Os componentes de layout não desenham conteúdo — eles organizam o espaço. Há três níveis:
- Shell de aplicação (
AppShell+Page+Container) — a moldura responsiva que segura navbar, sidebar/bottom-nav, header e conteúdo. - Primitivos de espaçamento (
Stack,Grid,Divider,Spacer,Center,AspectRatio) — flex/grid declarativos sem você escrever CSS. - Utilitários responsivos (
SafeArea,<Show>/<Hide>,ResponsiveValue) — adaptam o layout por breakpoint e respeitam notches/barras do sistema.
Quando usar: prefira esses primitivos a <div style={{ display: "flex" }}> ad-hoc. Eles usam os tokens de spacing do SDK (escala 4px), são responsivos por construção e mantêm o espaçamento consistente entre apps.
Componha de fora pra dentro
Pense AppShell → Container → Page → Stack/Grid → conteúdo. Cada camada tem uma responsabilidade única; empilhá-las dá um layout responsivo completo sem nenhuma media query manual.
AppShell
Seção layout da gallery — rode localmente para interagir.
Quando usar: como a moldura raiz de um app com navegação persistente (dashboard, painel admin). Para uma landing page simples, um Container basta.
Composer: navbar + sidebar (desktop) / bottomNav (mobile) + main + footer responsivo.
import { useState } from "react";
import {
AppShell,
BottomNavigation,
Navbar,
Page,
Sidebar,
type SidebarItem,
} from "tempest-react-sdk";
const NAV: SidebarItem[] = [
{ key: "dashboard", label: "Dashboard" },
{ key: "pedidos", label: "Pedidos", badge: 12 },
{ key: "clientes", label: "Clientes" },
];
export function Shell() {
const [tab, setTab] = useState("dashboard");
return (
<AppShell
navbar={<Navbar logo={<strong>Tempest</strong>} actions={<button>Sair</button>} />}
sidebar={<Sidebar items={NAV} value={tab} onChange={setTab} />}
bottomNav={<BottomNavigation items={NAV} value={tab} onChange={setTab} />}
footer={<small>© 2026 Tempest</small>}
sidebarBreakpoint="md"
>
<Page title="Dashboard">Conteúdo da aba {tab}</Page>
</AppShell>
);
}
Comportamento responsivo:
- >=
sidebarBreakpoint: navbar + sidebar + main + footer. - <
sidebarBreakpoint: navbar + main + bottomNav + footer (sidebar ocultado).
| Prop | Tipo | Default |
|---|---|---|
navbar |
ReactNode |
— |
sidebar |
ReactNode |
— |
bottomNav |
ReactNode |
— |
footer |
ReactNode |
— |
sidebarBreakpoint |
"sm" \| "md" \| "lg" \| "xl" |
"md" |
A navbar gruda no topo ao rolar: o wrapper dela não gera caixa
(display: contents), então o Navbar sticky se prende ao shell inteiro, e
sticky={false} no Navbar continua desligando. Antes da correção do #406 a barra
saía da tela junto com o conteúdo (top: -1261px com scrollY 1261, Chromium e
Firefox a 1440×900).
sidebar some abaixo do breakpoint — não é o mesmo que estar lá
Abaixo de sidebarBreakpoint o AppShell não renderiza a sidebar; ele
espera que você passe bottomNav. Se a navegação principal só existe na
sidebar, no mobile o app fica sem navegação nenhuma. Ou passe bottomNav,
ou abra a mesma lista num <Drawer> a partir de um botão no Navbar.
Page
Page wrapper com header (eyebrow + title + description + actions) + toolbar + content + footer.
import { Button, Page, Pagination, Table } from "tempest-react-sdk";
import { Download, Plus } from "lucide-react";
const PEDIDOS = [
{ id: "8421", cliente: "Ana Souza", total: "R$ 1.240,00" },
{ id: "8422", cliente: "Bruno Lima", total: "R$ 380,00" },
];
export function PedidosPage() {
return (
<Page
eyebrow="Vendas"
title="Pedidos"
description="Acompanhe seus pedidos em tempo real"
actions={
<>
<Button variant="ghost" leftIcon={<Download size={16} />}>
Exportar
</Button>
<Button leftIcon={<Plus size={16} />}>Novo</Button>
</>
}
footer={
<Pagination
page={1}
totalPages={3}
totalItems={PEDIDOS.length}
onPageChange={() => {}}
/>
}
>
<Table
data={PEDIDOS}
rowKey={(pedido) => pedido.id}
columns={[
{ key: "id", header: "Pedido" },
{ key: "cliente", header: "Cliente" },
{ key: "total", header: "Total" },
]}
/>
</Page>
);
}
| Prop | Tipo | Default |
|---|---|---|
title |
ReactNode |
— |
eyebrow |
ReactNode |
— |
description |
ReactNode |
— |
actions |
ReactNode |
— |
toolbar |
ReactNode |
— |
footer |
ReactNode |
— |
padded |
boolean |
true |
SectionHeader
O cabeçalho do Page (eyebrow + title + description + actions) como peça
própria, para uma página com várias seções ter um <h1> e vários <h2> com o
mesmo visual. O Page usa o SectionHeader com level={1} por dentro, então
os dois ficam iguais por construção.
Para a seção virar landmark, envolva com Section: ele renderiza <section> e
liga o aria-labelledby ao id do título do SectionHeader dentro dele
sozinho. Sem nome acessível, um <section> não é region — o leitor de tela
pula a seção na navegação por landmarks. Esse passo é sempre o mesmo, então é a
lib que faz.
import { Button, Page, Section, SectionHeader } from "tempest-react-sdk";
export function LandingPage() {
return (
<Page
eyebrow="TEMPEST"
title="Software sob medida"
description="Do protótipo à produção, com o mesmo time."
actions={<Button>Falar com a gente</Button>}
>
<Section>
<SectionHeader
eyebrow="Soluções"
title="O que podemos desenvolver"
description="Apps, painéis e integrações."
align="center"
/>
<p>Conteúdo da seção de soluções.</p>
</Section>
<Section>
<SectionHeader eyebrow="Processo" title="Como trabalhamos" />
<p>Conteúdo da seção de processo.</p>
</Section>
</Page>
);
}
A árvore de acessibilidade sai com um main, um heading nível 1 e duas
region nomeadas "O que podemos desenvolver" e "Como trabalhamos".
| Prop | Tipo | Default |
|---|---|---|
title |
ReactNode |
— |
eyebrow |
ReactNode |
— |
description |
ReactNode |
— |
actions |
ReactNode |
— |
level |
1 \| 2 \| 3 \| 4 \| 5 \| 6 |
2 |
align |
"start" \| "center" |
"start" |
id |
string |
useId() |
id vai no elemento do título (não no <header>); sem ele, um id é gerado com
useId. Com todos os slots vazios, nada é renderizado.
level é semântica, não tamanho
O visual é o mesmo em todo nível: você escolhe o nível pela hierarquia do
documento (um <h1> por página, <h2> para as seções), não pelo tamanho que
quer ver. Isso é o que deixa o h1 do Page e o h2 das seções iguais.
Section
<section> que pega o nome acessível do SectionHeader dentro dele. Aceita
todo atributo de <section>.
- O vínculo passa por contexto, então um
divou umCardentre oSectione oSectionHeadernão quebra nada. - Vale o primeiro
SectionHeadercomtitle, na ordem de render; se ele desmontar, o próximo assume. Sectionaninhado é nomeado pelo próprio cabeçalho, não pelo de fora.aria-labelouaria-labelledbypassados por você vencem o automático.- Sem
SectionHeadercom título, oaria-labelledbyfica de fora (depois de montar) em vez de apontar para um id que não existe. tone="inverse"pinta a seção na cor da marca e inverte todo token de cor dentro dela (escrevedata-tempest-tone="inverse"). É opt-in: carreguetempest-react-sdk/styles/inverse.cssou gere o tema cominverse: createInverseSurface; sem nenhum, a seção fica com os tokens da página. Os valores e as medições estão em Superfície invertida.tone="default"(oudata-tempest-tone="default"em qualquer elemento, como umCard) dentro de uma seção invertida devolve os tokens da página — claro ou escuro, o que a página estiver usando. Vem com as mesmas regras opt-in; veja De volta aos tokens da página.
O vínculo já está no primeiro render
O id é do Section (useId), e o primeiro SectionHeader com title
o põe no próprio título. Por isso o aria-labelledby sai já no primeiro
HTML — renderToString, pré-render — e a hidratação não muda o atributo.
Dois casos o servidor não tem como saber, porque o <section> é escrito
antes dos filhos: sem título nenhum, o atributo do HTML aponta para um id
que não existe até a hidratação o tirar (medido no Chromium 153: fica o
mesmo grupo anônimo de sem atributo, e o axe não acusa nada); e com id
explícito no primeiro SectionHeader, o atributo passa para esse id na
hidratação. Se você precisa do id explícito no HTML do servidor, passe
aria-labelledby no Section.
Container
Max-width wrapper.
import { Container, Page } from "tempest-react-sdk";
export function Settings() {
return (
<Container size="lg">
<Page title="Configurações">Preferências da conta</Page>
</Container>
);
}
size |
Max-width |
|---|---|
"sm" |
640px |
"md" |
768px |
"lg" |
1024px |
"xl" |
1280px |
"full" |
100% |
Stack
Seção advanced da gallery — rode localmente para interagir.
Quando usar: o primitivo padrão para empilhar elementos em uma dimensão (coluna ou linha) com espaçamento uniforme. Para grade 2D use Grid.
Flex vertical ou horizontal com gap, align, justify, wrap. Aceita ResponsiveValue em direction e gap.
import { Card, Stack } from "tempest-react-sdk";
export function Empilhado() {
return (
<>
<Stack direction="vertical" gap={4}>
<Card>Um</Card>
<Card>Dois</Card>
</Stack>
<Stack
direction={{ mobile: "vertical", desktop: "horizontal" }}
gap={{ mobile: 2, desktop: 4 }}
>
<Card>No mobile empilha, no desktop fica lado a lado</Card>
</Stack>
</>
);
}
| Prop | Tipo | Default |
|---|---|---|
direction |
"vertical" \| "horizontal" (ou ResponsiveValue) |
"vertical" |
gap |
number \| string (ou ResponsiveValue) |
2 (8px) |
align |
"start" \| "center" \| "end" \| "stretch" |
— |
justify |
"start" \| "center" \| "end" \| "between" |
— |
wrap |
boolean |
false |
gap numérico mapeia para escala 4px (2 → 8px, 4 → 16px).
Grid
CSS Grid wrapper.
import { Card, Grid, Stat } from "tempest-react-sdk";
const METRICAS = [
{ id: "receita", label: "Receita", value: "R$ 84.200" },
{ id: "pedidos", label: "Pedidos", value: "1.204" },
{ id: "ticket", label: "Ticket médio", value: "R$ 69,93" },
];
export function Painel() {
return (
<>
<Grid columns={3} gap={4}>
<Card>1</Card>
<Card>2</Card>
<Card>3</Card>
</Grid>
<Grid columns={{ mobile: 1, tablet: 2, desktop: 4 }} gap={3}>
{METRICAS.map((metrica) => (
<Stat key={metrica.id} label={metrica.label} value={metrica.value} />
))}
</Grid>
<Grid columns="2fr 1fr" gap={6}>
<article>Conteúdo</article>
<aside>Barra lateral</aside>
</Grid>
</>
);
}
| Prop | Tipo | Default |
|---|---|---|
columns |
number \| string (ou ResponsiveValue) |
2 |
gap |
number \| string |
4 |
columns numérico → repeat(N, minmax(0, 1fr)). String passa direto pra grid-template-columns.
Colunas responsivas sem media query
columns={{ mobile: 1, tablet: 2, desktop: 4 }} é a forma idiomática de uma grade que vira lista no mobile e abre colunas no desktop. As chaves são mobile · tablet · desktop — ResponsiveValue não usa os nomes de breakpoint (sm/md/lg) que aparecem em sidebarBreakpoint e em <Show above>. O minmax(0, 1fr) evita o overflow clássico de células com conteúdo largo (texto longo, <pre>).
Divider
Separador horizontal/vertical com label opcional.
import { Divider } from "tempest-react-sdk";
export function Separadores() {
return (
<>
<Divider />
<Divider variant="dashed" />
<Divider orientation="vertical" />
<Divider label="OU" align="center" />
</>
);
}
| Prop | Tipo | Default |
|---|---|---|
orientation |
"horizontal" \| "vertical" |
"horizontal" |
variant |
"solid" \| "dashed" \| "dotted" |
"solid" |
label |
ReactNode (só na horizontal) |
— |
align |
"start" \| "center" \| "end" (label) |
"center" |
Spacer
Flex push.
import { Button, Spacer, Stack } from "tempest-react-sdk";
export function AcoesDoFormulario() {
return (
<Stack direction="horizontal">
<Button variant="ghost">Cancelar</Button>
<Spacer />
<Button variant="primary">Salvar</Button>
</Stack>
);
}
| Prop | Tipo | Default |
|---|---|---|
axis |
"both" \| "x" \| "y" |
"both" |
Center
Centraliza children horizontal/vertical/ambos.
import { Center, Spinner } from "tempest-react-sdk";
export function Carregando() {
return (
<Center axis="both" minHeight="100vh">
<Spinner />
</Center>
);
}
| Prop | Tipo | Default |
|---|---|---|
axis |
"both" \| "horizontal" \| "vertical" |
"both" |
minHeight |
number \| string |
— |
fullWidth |
boolean |
true |
Centralizar na vertical precisa de altura
axis="both" centraliza dentro do espaço que o Center tem. Num pai sem
altura definida esse espaço é a altura do próprio conteúdo, e nada parece
acontecer. Passe minHeight (ou dê altura ao pai) sempre que a centralização
vertical importar.
AspectRatio
Seção display-media da gallery — rode localmente para interagir.
Preserva proporção pra media.
import { AspectRatio } from "tempest-react-sdk";
export function Midia() {
return (
<>
<AspectRatio ratio={16 / 9}>
<img
src="/cover.jpg"
alt="Capa do álbum"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</AspectRatio>
<AspectRatio ratio={1}>
<video src="/clip.mp4" autoPlay loop muted />
</AspectRatio>
</>
);
}
| Prop | Tipo | Default |
|---|---|---|
ratio |
number |
16 / 9 |
Usa CSS aspect-ratio nativo. Compatível com todos navegadores modernos.
AspectRatio reserva o espaço antes da imagem chegar
É o que evita o salto de layout: a caixa já tem a proporção final, então o
texto abaixo não desce quando a imagem carrega. Por isso a criança precisa
preencher a caixa — uma <img> sem width: 100%; height: 100% fica no
tamanho natural dela e a proporção deixa de valer para o que se vê.
SafeArea
Padding por edge usando env(safe-area-inset-*).
import { SafeArea } from "tempest-react-sdk";
export function Raiz() {
return (
<SafeArea edges={["top", "bottom"]}>
<main>Conteúdo que não some atrás do notch</main>
</SafeArea>
);
}
| Prop | Tipo | Default |
|---|---|---|
edges |
("top" \| "right" \| "bottom" \| "left")[] |
["top","right","bottom","left"] |
inline |
boolean (display: contents em vez de block) |
false |
Componentes que já cuidam de safe-area automaticamente: Navbar (top), BottomNavigation/BottomSheet (bottom), Modal.fullscreen (todos), Toast (top+bottom).
Não empilhe SafeArea em quem já trata
Se você já usa Navbar/BottomNavigation/Toast, não envolva-os de novo em SafeArea — o padding dobra e cria um vão visível. Use SafeArea só em superfícies custom (um sheet ou overlay próprio).
<Show> / <Hide>
Quando usar: trocar de árvore inteira por breakpoint (nav desktop vs. mobile, por exemplo). Para apenas ocultar via CSS sem desmontar, prefira CSS responsivo — <Show>/<Hide> desmontam o componente do DOM.
Conditional render baseado em breakpoint. SSR-safe — primeiro render usa xs (mobile first), re-renderiza ao client.
import { Banner, Hide, Show } from "tempest-react-sdk";
export function PorBreakpoint() {
return (
<>
<Show above="md">
<nav>Navegação de desktop</nav>
</Show>
<Hide above="md">
<nav>Navegação de mobile</nav>
</Hide>
<Show below="lg">
<Banner>Promoção de lançamento</Banner>
</Show>
<Show only={["sm", "md"]}>
<p>Dica que só aparece em tablet</p>
</Show>
</>
);
}
| Prop | Tipo |
|---|---|
above |
Breakpoint (xs/sm/md/lg/xl/2xl) |
below |
Breakpoint |
only |
Breakpoint \| Breakpoint[] |
only sobrescreve above/below quando setado.
Show/Hide desmontam — não escondem com CSS
A decisão vem de useBreakpoint, que mede window.innerWidth em JavaScript,
então atravessar o breakpoint desmonta a subárvore e todo estado local
dentro dela se perde: input meio preenchido, acordeão aberto, scroll. Para
esconder mantendo o estado, use CSS (a camada utilities.css tem as classes
de visibilidade). E como a largura inicial é 0 fora do browser, um
<Show above="md"> renderiza null na primeira passada até o efeito medir —
o que é correto para um SPA e não é render de servidor.
Responsive values
Stack.direction, Grid.columns, Form.layout aceitam ResponsiveValue<T>:
type ResponsiveValue<T> = T | { mobile?: T; tablet?: T; desktop?: T };
Falls back para o último valor definido por breakpoint cascading.
A11y geral
Page.titleé<h1>— apenas um por página para hierarquia correta. As seções abaixo dele usamSectionHeader(<h2>por default) dentro deSection, que viraregioncom nome.AppShellenvolve em<main>semântico.<Show>/<Hide>renderizamnullno servidor + ajustam no client (no SEO impact, primeira tinta pode flickerizar).
Resumo
- Componha de fora pra dentro:
AppShell→Container→Page→Stack/Grid→ conteúdo. - Use os primitivos (
Stack/Grid/Spacer/Center) em vez de CSS flex/grid ad-hoc — eles usam os tokens de spacing (escala 4px) e são responsivos por construção. direction/columns/layoutaceitamResponsiveValue→ layout responsivo sem escrever media query.SafeAreasó em superfícies custom;Navbar/BottomNavigation/Toast/Modal.fullscreenjá tratam o notch.
Páginas relacionadas:
- Navegação —
Navbar,Sidebar,BottomNavigationque preenchem os slots doAppShell. - Entrada de dados —
Form/FormSection/FormRow/FormActionspara estruturar campos dentro de umPage. - Dados —
Table/DataTable/Paginationque vivem no conteúdo de umPage. - App Providers e Roteamento — a glue que envolve o
AppShell.


