| name | saas-panel-design-system |
| description | Apply a dense, token-driven design system for SaaS admin panels built with React + TypeScript + Tailwind CSS + shadcn/ui. Use when building, restyling, or reviewing panel UI — list/table screens, detail modals, dashboards with KPIs and charts, report screens, hub pages, forms, kanban boards, the navigation shell, or the color/typography/spacing foundations. Triggers include "design system", "apply the DS", "list screen", "data table screen", "detail modal", "dashboard cards", "report screen", "hub page", "admin panel UI", "shadcn theme", "dark mode tokens", "make this look consistent". |
| license | MIT |
| compatibility | Requires a React 18+ project with TypeScript and Tailwind CSS 3. Assumes shadcn/ui primitives; React Router and TanStack Query are used by the examples but are not required for the tokens and visual patterns. |
| metadata | {"version":"1.0.0","body-language":"en","reference-language":"pt-BR","source":"extracted from a production multi-tenant lead-generation SaaS panel"} |
SaaS Panel Design System
A complete design system for dense operational panels — the kind of product
people keep open all day: CRMs, admin backoffices, analytics consoles.
Extracted from a production SaaS and packaged so an agent can apply it end to end.
It is opinionated on purpose: one solution per problem, no "it depends".
Reference docs are in Brazilian Portuguese (references/), and UI strings
in the examples are pt-BR. The system itself is language-agnostic — when
applying it to a project in another language, translate the UI strings and keep
the structure. Code identifiers are always English.
0. First: is the system already installed here?
Check the target project before writing any component:
test -f tailwind.config.ts -o -f tailwind.config.js && echo "tailwind ok"
grep -rl "primary-glow" src/index.css src/app/globals.css 2>/dev/null
ls src/components/ListCard.tsx src/components/DetailModal.tsx 2>/dev/null
- Tokens missing → run the bootstrap first: read
../ds-install/SKILL.md
(or tell the user to run /ds-install). Do not hand-write tokens.
- Tokens present, component missing → copy it from
assets/components/
rather than reinventing it. These files are production code, not sketches.
- Everything present → go straight to the pattern you need (§4).
Never partially apply the system. A screen with the right colors but a fixed
PAGE_SIZE and a <Sheet> for details is not "mostly compliant" — it breaks
the two rules users notice most.
1. Non-negotiable decisions
Each row is a rule, not a preference. Violating one is a review blocker.
| Problem | The only answer | Never |
|---|
| Sub-navigation inside a page | <PageSubnav> (left rail) | <Tabs> |
| Show a record's details | <DetailModal> (modal + section sidebar) | <Sheet> / drawer |
| Edit a record's fields | <InlineField> where the value is read | an "Edit" tab or a second modal |
| Tabular listing screen | PageHeader → StatsGrid → filter Card → ListCard | fixed PAGE_SIZE, a "Project/Tenant" column |
| Card portal to other routes | <HubPage> | ad-hoc <Card> grid |
| Select from a list that can grow | searchable combo (Popover + Command) | <Select> with dozens of items |
| Go back up the hierarchy | the shell's breadcrumb | a "Back" button on the page |
| Destructive action | AlertDialog naming the record | window.confirm, silent delete |
| Blocked action | disabled button + Tooltip with the reason | hiding the button |
| Chart / KPI | <ChartCard> / <KpiCard> from DashboardKit | bare Recharts in a <Card> |
2. Foundations you can apply without opening any file
Color. Every color is an HSL triplet in a CSS variable — no hsl(), no
commas — because that is what makes bg-primary/10 and text-primary/60 work.
--primary: 142 56% 31% --background: 0 0% 100% / dark 210 9% 11%
--primary-glow: 142 52% 42% --foreground: 210 9% 13% / dark 210 17% 95%
--muted-foreground: 210 8% 46% --border: 220 13% 91% / dark 210 9% 22%
--success: 142 72% 39% --warning: 43 96% 56% --destructive: 4 90% 58%
--radius: 8px
--primary-glow exists for dark mode. A 31%-lightness green disappears on
a dark background, so every solid accent in dark uses
dark:text-primary-glow / dark:bg-primary-glow. Translucent fills
(bg-primary/10) keep using primary.
- Non-semantic accents (categories, hub cards, dashboard views) come from a
closed vocabulary:
emerald, sky, violet, amber, rose, primary.
Light uses solid 50/600/700; dark uses /10 + 300/400. Never a new hex.
- Full detail:
references/01-fundacoes-cores.md.
Type. Inter only, weights 400/500/600/700. Headings differ by weight and
size, not family. Numbers the user compares vertically always get
tabular-nums, formatted through Intl in the app locale. Missing value renders
—, never R$ 0,00 or 0%.
There is one trick worth knowing before you touch any size:
html:has(.app-shell) { font-size: 90%; }
so text-2xl renders ~21.6px inside the panel. Values in px
(text-[11px], h-9) do not shrink — which is why micro-labels use px.
Detail: references/02-tipografia.md.
Density. This is what makes it feel like a tool and not a landing page:
controls h-9 (button, input, select) · table head h-10 · row ~52px
card rounded-xl border-border/60 · modal max-h-[88vh], width persisted
page container space-y-6 · dashboard space-y-4
icons w-4 h-4 in buttons, w-5 h-5 standalone
Separation comes from borders, not shadows (hover:shadow-sm is as far as a
card goes). Detail: references/03-espacamento-layout-elevacao.md.
3. The primitives are patched — do not use stock shadcn
assets/primitives/ holds shadcn components with the system's diffs already
applied. Copy those over the stock ones after shadcn add. The five that
matter most:
button — h-9 (not h-10), shadow-none primary, focus ring
ring-primary/20 ring-offset-0, ghost starts as text-muted-foreground.
card — rounded-xl border-border/60, p-5, hover:shadow-sm, CardTitle
is text-base font-medium.
input / textarea / select — h-9, focus border-primary ring-1 ring-primary/20, disabled:bg-muted/50.
table — TableHead is uppercase text-xs tracking-wide text-muted-foreground;
rows hover:bg-muted/40, selection bg-primary/5; borders at /40–/50.
dialog — p-5 gap-3.
badge and the rest are stock. Full diff list: references/04-primitivos-ui.md.
4. Screen patterns — read the reference, then copy the example
Each pattern has a doc (the rules and the why) and a runnable example (the
skeleton). Read the doc before writing the screen; skipping it is how the
subtle rules get lost.
| Building… | Read | Copy from | Components needed |
|---|
| List / table screen | references/06-padrao-lista.md | assets/examples/ExampleListPage.tsx | PageHeader, StatsGrid, ListCard, SortableHead, table-pagination, useSortedRows, useDebounce |
| Hub / portal page | references/07-padrao-hub.md | assets/examples/ExampleHubPage.tsx | HubPage |
| Dashboard | references/08-padrao-dashboard.md | assets/examples/ExampleDashboardSection.tsx | DashboardKit, StatsGrid |
| Report screen | references/09-padrao-relatorios.md | assets/examples/ExampleReportSection.tsx | ReportsNav, ReportTable, csv.ts |
| Record detail | references/10-padrao-modais.md | assets/examples/ExampleDetailModal.tsx | DetailModal, InlineField, useModalWidth |
| Create/edit form | references/11-padrao-cadastro-formularios.md | assets/examples/ExampleFormModal.tsx | RHF + Zod, searchable combo |
| Kanban board | references/12-padrao-kanban.md | — | (composed in-place) |
| App shell / nav | references/05-shell-navegacao.md | assets/shell/ | AppShell, AppSidebar, Breadcrumbs, SidebarContext |
The two mechanics people get wrong
Adaptive pagination. Row count is whatever fits the viewport, measured from
the real DOM and recomputed on resize — never a fixed PAGE_SIZE.
const { paginatedItems, containerRef, availableHeight, pageSize, ... } =
usePagination(sorted, { auto: true });
<ListCard ref={containerRef} availableHeight={availableHeight}
footer={<TablePagination … />}>
It uses a callback ref (not useRef + effect) because the container mounts
only after loading resolves, and it re-measures on frames, timers and
document.fonts.ready because the first layout after a refresh is raw.
Pipeline order. filter → sort → paginate, always. Sorting must run over the
whole list, before pagination — sorting the visible page is a lie:
const filtered = useMemo(() => …, [items, debouncedSearch, statusFilter]);
const { sorted, sort, onSort } = useSortedRows(filtered, accessors, initial);
const { paginatedItems, resetPage, … } = usePagination(sorted, { auto: true });
useEffect(() => { resetPage(); }, [debouncedSearch, statusFilter, sort, resetPage]);
5. Required states — a screen without them is unfinished
loading → error → empty → content, every time.
<div className="animate-spin w-6 h-6 border-2 border-primary border-t-transparent rounded-full" />
<Card className="border-destructive">…{error}…<Button variant="outline" onClick={refetch}>Tentar Novamente</Button></Card>
<TableRow><TableCell colSpan={n} className="text-center py-8 text-muted-foreground">
{search || filter !== 'all' ? 'Nenhum item encontrado' : 'Nenhum item cadastrado ainda'}
</TableCell></TableRow>
Never early-return an empty state that replaces the whole screen: it hides the
header, KPIs and filters, and traps the user with no way to change the filter.
Detail: references/13-estados-feedback.md.
6. Conventions that keep the system coherent
Carry these into the target project's CLAUDE.md / AGENTS.md:
- One solution per problem (§1) — no local variations.
- Details = modal with sections; never a drawer; never an "Edit" tab.
- Page sub-navigation = left rail; never
Tabs.
- List = 4 blocks +
filter → sort → paginate + adaptive pagination.
- Growable list = searchable combo.
- Loading/error/empty always covered; error always has retry.
- Destructive =
AlertDialog naming the record and its consequence.
- Blocked action = disabled + tooltip explaining why.
- Toasts live in the data hook, not in the component (one action, one toast).
- User-chosen state lives in the URL (
?tab=, ?view=, ?period=).
- Zero loose hex; accents only from the closed vocabulary.
tabular-nums on comparable numbers; Intl for formatting; — for absent.
7. Antipatterns worth memorizing
| Never | Why it broke | Instead |
|---|
Fixed PAGE_SIZE = 10 | Half-empty screen on a big monitor | usePagination(items, { auto: true }) |
| Sorting only the current page | Sorting becomes a lie | useSortedRows before paginating |
| Early-return empty state | Hides filters; user gets stuck | Empty row inside <TableBody> |
useRef + effect to measure | Measures a null node, sticks at minimum | callback ref |
| Fixed modal height | Tall, empty modal | max-h-[88vh] + scrolling body |
Solid primary accent in dark | Unreadable dark smear | dark:*-primary-glow |
| Hex in a component | Kills dark mode and /10 variants | token or accent vocabulary |
| Autosave without invalidating the detail query | Field saves, UI reverts | invalidate the detail's own query key |
Text without truncate in flex | Layout blows out | min-w-0 on parent + truncate |
Editing components/ui/** by hand | Lost on the next shadcn update | wrapper in components/ |
Full catalog plus the historical bug behind each rule:
references/16-checklists-e-antipadroes.md.
8. File map
SKILL.md this file
references/ 17 docs, pt-BR — read on demand (see references/README.md)
assets/
tokens.css all tokens, light + dark — the starting point
tailwind.config.example.ts config that reads the tokens
primitives/ shadcn WITH the system's diffs applied
components/ the system's own components + hooks (production code)
shell/ AppShell, AppSidebar, Breadcrumbs, SidebarContext (brand-neutral)
examples/ 6 complete reference screens
Companion skills in this package:
ds-install — bootstrap the system into a project (deps, tokens, config,
primitives, components). Run this before the first screen.
ds-audit — audit existing code against the system and report violations.
9. Rebranding
Three lines in assets/tokens.css change the whole product:
--primary: <H S% L%>;
--primary-glow: <same hue, ~10 points lighter>;
--ring: <same as primary>;
If the brand's lightness is above ~55%, revisit --primary-foreground (it may
need dark text). The sidebar gradient in assets/shell/AppSidebar.tsx is brand,
not system — replace it. Everything else derives.