Tangram
A React 19 admin and dashboard template. Flat bento tiles on a deep green canvas, pills for every control, giant Bricolage Grotesque numerals and a floating island sidebar. This is the manual — start at Quick start if you just want it running.
Introduction
Thank you for buying Tangram. This document covers everything from installing it to deploying it, and it is written on the assumption that you intend to build a real product on top of it rather than demo it once.
What Tangram is
Tangram is a front-end admin template: a complete, working React application with 84
routes, a design system, a component kit and a typed demo data layer. It is
not a backend, and it does not talk to an API — every figure you see comes from
static files under src/data/demo/, which are there to be replaced by your
own data layer. That is true of every admin template on the marketplace; it is stated
here plainly so it is not a surprise after the download.
The design language
The canvas color is the brand. Everything else is a tile placed on it. There are no borders, no hairlines and no shadows anywhere in the product — depth is made out of hard color blocks and 2px ink lines, and separation is made out of the canvas showing between tiles.
That has a consequence worth knowing before you extend it: if you add a component that reaches for a border or a box-shadow to separate itself from what it sits on, it will look like it came from a different product. Use a tile, a tone, or the canvas gap.
- Tiles — 28px radius, 22px padding, bone by default; 20px and 18px in compact density.
- Hierarchy — one hero tile in the accent, a few colored tiles for things that need attention, bone tiles for plain information.
- Controls — every one is a full pill. Icons always sit inside a pill.
- Type — Bricolage Grotesque 700–800 for display and numerals, Figtree 400–700 for text at 14px.
- Chrome — a floating island sidebar on the canvas, and no top bar. The page header sits directly on the canvas in bone text.
React, and only React
This is one React 19 project — source, configs and a pinned lockfile. It is not an HTML template with a React port bolted on, and there is no second framework in the zip. Every page is a React component, routing is React Router, and the type-check runs before the bundle.
What is in the box
| Area | Coverage |
|---|---|
| Routes | 84, every one a real page — there is no placeholder behind a nav item |
| Dashboards | 7 — home, sales, traffic, product performance, customer cohorts, payments, loyalty |
| Full-height apps | 8 — shipping queue, customer inbox, social planner, media library, team inbox, chat, calendar, notes |
| Lists and records | 26 list pages and 5 record pages, all on shared archetypes |
| Forms | 9 form and editor pages, including a campaign builder and a post editor |
| Auth and errors | 7 auth screens and 6 error pages |
| Design kit | 13 pages documenting the components inside the product itself |
| Theming | 2 themes · 5 color schemes · 2 densities · 2 island modes · LTR/RTL · motion axis |
| Charts | 13 ApexCharts types behind one lazy wrapper, plus 4 in-house SVG charts |
What's included
The download contains two folders and nothing else:
tangram-1.0.0/
├── template/ the React project — this is the product
│ ├── src/
│ ├── public/
│ ├── index.html
│ ├── package.json
│ ├── package-lock.json
│ ├── vite.config.ts
│ ├── tsconfig*.json
│ ├── eslint.config.js
│ ├── .prettierrc .stylelintrc.json .nvmrc .gitignore
│ └── README.md a short getting-started, also in the project
└── Documentation/
└── index.html this file
template/
The whole project, ready to npm install. The lockfile is included and
pinned exactly, so the install you get is the install this was built and tested against.
Documentation/
This manual, as one self-contained file. It needs no server — double-click it. The only thing it fetches is the two webfonts; offline it falls back to your system sans, and everything still reads.
What is deliberately not included
- No stock photography and no paid assets. Every illustration, avatar and the mark itself are original SVGs made for this template, so there is nothing you have to license separately or strip before you ship.
- No PSD or Sketch files. The design system is the token files plus the 13 design-kit pages, which are part of the running app and therefore cannot drift from what ships.
- No second framework. See the FAQ.
Requirements
| Tool | Version | Notes |
|---|---|---|
| Node.js | ^20.19 or >=22.12 | The line is pinned in .nvmrc. Vite 8 requires it. |
| npm | 10 or newer | Ships with those Node versions. pnpm and yarn work; the lockfile is npm's. |
| A browser | Any current one | Chrome, Edge, Firefox and Safari, last two versions each. |
Node is a build tool here, not a runtime
You need Node to develop and to build. You do not need it to host the result:
npm run build produces a folder of static files that any web server can
serve. See Deployment.
An older Node will fail in a confusing way. Vite 8 uses Rolldown, and on Node
18 the build dies with a native module error rather than a version message. Check with
node -v before anything else.
Quick start
cd template
npm install
npm run dev
That starts the dev server on http://localhost:5193 and you land on the
home dashboard. The port is fixed rather than auto-incremented, so a stale server on the
same port is an error you see rather than a second app you do not notice.
The scripts
| Command | What it does |
|---|---|
npm run dev | Dev server with hot reload. |
npm run build | Type-checks the project references, then builds into dist/. |
npm run preview | Serves dist/ locally, so you can check the real build. |
npm run typecheck | tsc -b --noEmit. |
npm run lint | ESLint over the repo — flat config, type-aware. |
npm run lint:css | Stylelint over src/**/*.css. |
npm run format | Prettier over the tree. format:check only reports. |
npm run check | The gate: typecheck → ESLint → Stylelint → the four source checks. See Quality gates. |
If the page loads unstyled, the Tailwind step did not run. That is almost always
an interrupted npm install: delete node_modules and
package-lock.json is not the fix — keep the lockfile, delete only
node_modules, and run npm ci.
Project structure
src/
├── main.tsx boot: styles, theme, mount
├── App.tsx the router and the shell chooser
├── styles/
│ ├── app.css the single CSS entry
│ ├── tokens/ THE DESIGN SYSTEM — see Theming
│ ├── base.css reset, focus ring, scrollbars
│ ├── shell.css the island, page chrome, the customizer
│ ├── nav.css navigation, active states
│ ├── components.css the .tg-* component classes
│ └── utilities.css a few utilities Tailwind lacks
├── components/
│ ├── ui/ 28 primitives, barrel at index.ts
│ ├── charts/ Chart (Apex wrapper) + Bars, Line, Ring, Spark
│ ├── motion/ CountUp, Draw, Stagger
│ ├── patterns/ the page archetypes
│ └── shell/ shells, island, page head, palette, customizer
├── context/ CustomizerContext + store
├── hooks/ useAxis, useGo, useHotkey, useFocusTrap, …
├── lib/
│ ├── theme.ts the axis registry: attribute ↔ storage ↔ event
│ ├── storage.ts the tg: localStorage helper, schema-guarded
│ ├── manifest.ts typed access to the route manifest
│ └── format.ts number, currency, date, percent (Intl, en-US)
├── data/
│ ├── nav-manifest.json the route registry
│ └── demo/ the demo world, typed
└── pages/
├── pageRegistry.ts file path → slug → lazy component
└── <group>/<Name>.tsx one file per route, 84 of them
The four pillars
styles/tokens/— every color, radius and duration in the product. Nothing outside this folder states a literal color.data/nav-manifest.json— every route. The island, the breadcrumbs, the command palette and the router all read it.components/— the kit. The 13 design-kit pages underpages/ui/document it from inside the app.data/demo/— every fact the pages show. Delete it and TypeScript shows you every call site.
The stylesheet, in load order
app.css is the only entry, and the order in it matters:
@import "tailwindcss"tokens/_primitives.css— the raw rampstokens/_typography.css— families and metricstokens/_roles.css— semantic roles, the default themetokens/_themes.css— the[data-tg-*]variantstokens/_motion.cssandtokens/_charts.csstokens/_recipes.css— the surface recipes (.tg-tileand friends)base.css, then the layered component sheets
Routing & the manifest
There is no route table to maintain. src/data/nav-manifest.json is the
single list of routes, and pageRegistry.ts globs the page files and derives
each slug from its path:
src/pages/orders/OrderDetail.tsx → slug "orders/order-detail"
src/pages/ui/Buttons.tsx → slug "ui/buttons"
A file whose name cannot kebab into its slug — a TSX component name may not start with a
digit — is pinned in SLUG_OVERRIDES and also exports a SLUG
constant. The five numeric error pages are the only cases in the product.
A manifest entry
{
"slug": "orders/order-detail",
"title": "Order",
"kind": "detail",
"shell": "island",
"keywords": ["order detail", "fulfillment", "timeline"]
}
| Field | Meaning |
|---|---|
slug | The URL path and the registry key. |
title | Shown in the island, the breadcrumb, the palette and <title>. |
kind | The archetype: dashboard, list, detail, form, app, page, kit, auth, error. |
shell | island (the sidebar), focus (full-height apps), bare (auth and errors). |
badge | Optional. A key resolved through data/demo/badges.ts, never a constant. |
keywords | Optional. Extra search terms for the command palette. |
The three shells
IslandShell— the floating sidebar and a page head on the canvas. Most routes.FocusShell— for the eight full-height apps: the page owns the viewport and scrolls inside its own panes rather than as a document.BareShell— auth and error pages, which have no chrome at all.
The page contract
scripts/check-manifest.mjs enforces this, and it is worth following in your
own pages because it is what keeps the product's pieces swappable. A page:
- default-exports a component;
- does not import the router — row clicks and redirects go through
useGo(), andLink/Buttontake a manifest slug into; - does not import a context — the toast bus (
lib/toast.ts) anduseAxiscover what a page needs; - does not call
fetch— every figure comes fromsrc/data/demo/.
The third one is the point of the exercise: because no page imports the router directly, moving the whole app under a sub-path, or swapping the router, is a change in two files rather than eighty-four.
Adding & editing pages
The two things every page needs
A file, and a manifest entry. Do one without the other and the check script tells you.
1. Add the route
// src/data/nav-manifest.json — inside the group you want it in
{
"slug": "analytics/margins",
"title": "Margins",
"kind": "dashboard",
"shell": "island",
"keywords": ["profit", "cost"]
}
2. Create the file
// src/pages/analytics/Margins.tsx
import { Kpi } from '@/components/patterns/Kpi';
import { Tile } from '@/components/ui';
import { Chart } from '@/components/charts';
import { PageHead } from '@/components/shell/PageHead';
export default function Margins(): React.JSX.Element {
return (
<>
<PageHead title="Margins" subtitle="Gross margin by collection, this quarter." />
<div className="tg-bento">
<Kpi span={5} tone="butter" label="Gross margin" value="62.4%" delta={2.1} />
<Tile span={7} title="By collection">
<Chart
type="bar"
label="Gross margin by collection"
categories={['Kitchen', 'Bath', 'Bedroom', 'Living']}
series={[{ name: 'Margin', data: [64, 58, 71, 55] }]}
unit="%"
/>
</Tile>
</div>
</>
);
}
3. Check it
npm run check
That is the whole loop. The island picks the page up from the manifest, the command palette indexes its title and keywords, the breadcrumb derives itself from the slug, and the router lazy-loads the file.
Start from a neighbor, not from scratch. The fastest way to add a list page is to copy the closest existing one and change its columns and its data source — the archetypes carry the empty state, the pagination, the filter row and the compact density behavior, and re-deriving those by hand is how a page ends up subtly different from its siblings.
The archetypes
kind | Build it on |
|---|---|
list | ListPage — toolbar, filters, Table, pagination, empty state |
detail | DetailPage — a record header, tabs, a bento of tiles |
form | FormPage — sectioned Fields, a sticky action bar |
app | AppFrame + Pane — a full-height two or three pane layout |
dashboard / page | A tg-bento grid of Tile and Kpi |
auth | AuthCard |
error | ErrorPage |
Removing pages
Delete the file and its manifest entry. Nothing else references it — that is the point of
the registry. If you delete a whole group, remove the group object too, and run
npm run check: it will tell you if anything still points at the slug.
Components
28 primitives, imported from one barrel. The 13 pages under Design kit in the running app are the live reference: each one renders every variant and state of what it documents, so it cannot describe something the product does not do.
import { Button, Tile, Table, Field, Input, Select, Status } from '@/components/ui';
| Group | Components |
|---|---|
| Actions | Button, Dropdown (+ MenuItem, MenuLabel, MenuSeparator), Segmented, Link, NavLink |
| Forms | Field, Fields, Input, SearchInput, Textarea, Select, FilterSelect, Checkbox, Radio, Switch, Stepper |
| Surfaces | Tile, TileHead, Dialog, Drawer, Tabs, TabPanel |
| Data | Table, Pagination, Dl, Track, Stars, MiniCalendar, Steps |
| Status | Status, Chip, Count, Delta, Alert, Skeleton, SkeletonRows, EmptyState |
| Media | Avatar, AvatarStack, Thumb, Icon |
| Overlay | Tooltip, Kbd |
Tile
The tile is the product. Everything else sits in one.
<Tile tone="butter" span={5} title="Revenue · September" more={{ label: 'All', to: 'analytics/sales' }}>
…
</Tile>
| Prop | Type | Notes |
|---|---|---|
tone | bone | butter | mint | coral | green | inset | Default bone. See the tone rules below. |
span | 1–12 | Bento column span. |
tall | boolean | Span two bento rows. |
title | ReactNode | Renders a TileHead. |
more | { label, to?, onClick? } | The trailing link in the head. to is a manifest slug. |
actions | ReactNode | Controls in the head. |
as | section | article | div | li | Default section. |
Tone is hierarchy, not decoration. One butter tile per screen — it is the hero, and a second one means neither is. Mint says "this is fine", coral says "this needs you now", green is a piece of deeper canvas for something secondary, and bone is everything else. A dashboard where five tiles are colored reads as a dashboard where none of them are.
Button
<Button variant="primary" icon={IconPlus}>New order</Button>
<Button variant="ghost" to="orders/list">All orders</Button> {/* a router link, styled as a pill */}
<Button variant="quiet" iconOnly icon={IconDots}>More</Button> {/* children become the label */}
| Prop | Values |
|---|---|
variant | surface (default), primary, accent, danger, ink, soft, line, ghost, quiet |
size | sm | md | lg |
icon, iconEnd | A Tabler icon component |
iconOnly | Square pill; the children become the accessible label |
loading, selected, block, round, dot | Booleans |
to | A manifest slug — renders a router link |
Table
Columns are declared, not written as markup, so the compact density, the sticky header, the row bleed on selection and the responsive collapse all come for free:
const columns: Column<Order>[] = [
{ key: 'id', head: 'Order', cell: (o) => `#${o.id}` },
{ key: 'customer', head: 'Customer', cell: (o) => <Avatar name={o.customer} withName /> },
{ key: 'total', head: 'Total', align: 'end', cell: (o) => money(o.total) },
{ key: 'status', head: 'Status', cell: (o) => <Status value={o.status} /> },
];
<Table rows={orders} columns={columns} onRowClick={(o) => go(`orders/order-detail`)} />
Charts
Two families, for two different jobs. The in-house SVG set — Bars,
Line, Ring, Spark — draws the signature charts the
design depends on, with the motion written by hand. ApexCharts covers the breadth:
13 types behind one wrapper.
Apex is lazy. It is a dynamic import inside apex.ts, so no chart code
is in the first bundle and a page with no chart pays nothing for it.
Using a chart
import { Chart, Ring, Spark } from '@/components/charts';
<Chart
type="area"
label="Revenue over the last 30 days" {/* required: the accessible name */}
series={[{ name: 'Revenue', data: [12, 18, 15, 22, 28] }]}
categories={['W1', 'W2', 'W3', 'W4', 'W5']}
height={280}
curve="smooth"
/>
| Prop | Notes |
|---|---|
type | area · line · bar · column · donut · pie · radialBar · polarArea · heatmap · radar · treemap · candlestick · scatter |
series | ApexCharts' own series shape. |
label | Required. The chart renders with role="img" and this as its aria-label. |
categories, labels | Axis categories or slice labels. |
color | 1–6 — force one series color from the chart palette. |
stacked, horizontal, sparkline, legend, tooltip, values, track | Booleans. |
unit, centerLabel, centerValue, curve, height | Formatting and shape. |
Charts re-color with the scheme, live
Every series reads a --tg-chart-* token rather than a hex, and the wrapper
listens for the tg:change event the theme runtime fires. Switch scheme or
mode with a chart on screen and it repaints in place — no reload, no remount.
To retune the palette, edit src/styles/tokens/_charts.css. It defines six
series colors per theme, and they are deliberately chosen to sit within a narrow
lightness band so a six-series chart reads as one palette rather than a rainbow.
A chart mark is not the same tone as a tile. A tile tone is a fill that carries
ink on top of it; a mark is a graphic that has to be told apart from the tile behind it,
which WCAG puts at 3:1. That is why the tokens name
--tg-accent-mark, --tg-good-mark and --tg-bad-mark
separately from --tg-accent and friends. If you add a chart color, add a
mark tone with it.
Maps & icons
Maps
The Live view uses Leaflet with OpenStreetMap tiles, loaded lazily the same way Apex is. OpenStreetMap's tiles are ODbL and require attribution — the attribution control is on by default; leave it. If you expect real traffic, move to a tile provider with a plan (Mapbox, MapTiler, Stadia) rather than pointing volume at OSM's public servers.
Icons
Tabler icons (MIT), imported per icon so the bundler can drop the rest, and always
rendered through <Icon> so the 2.1px stroke and the pill are applied in
one place:
import { IconShoppingBag } from '@tabler/icons-react';
import { Icon } from '@/components/ui';
<Icon icon={IconShoppingBag} />
The design-kit's Icons page lists the set the product actually uses. Any of Tabler's ~5,900 outline icons drops in the same way.
Import the icon, not the package.
import { IconX } from '@tabler/icons-react' tree-shakes;
import * as Icons pulls the whole set into your bundle.
Motion
Motion is part of the product rather than a polish pass, and it follows four rules:
- Tiles rise in on a 50ms stagger when a page mounts —
<Stagger>. - Charts draw — bars grow, lines draw, rings sweep — and numerals count up once, never on a re-render.
- Tiles are static under the cursor. Nothing scales on hover. Hover feedback is a tone change.
- Sticky and fixed chrome never gets an entrance animation. The island does not fly in.
The last two are enforced: scripts/check-motion.mjs fails the build on an
entrance animation targeting sticky or fixed chrome, and on transitions of properties
that thrash layout.
import { Stagger, CountUp, Draw } from '@/components/motion';
<Stagger>
<Tile>…</Tile> {/* each child rises 50ms after the one before */}
<Tile>…</Tile>
</Stagger>
<CountUp to={128400} format="money" />
Turning it down
All of it sits behind two switches, and both are respected everywhere:
the OS's prefers-reduced-motion, and the product's own
data-tg-motion="reduced" axis, which a user can set in the customizer.
useReducedMotion() reads both.
The durations and easings are tokens in tokens/_motion.css. To slow
everything down or speed it up, change them there rather than at call sites.
Demo data
Everything the pages show comes from src/data/demo/: 31 JSON files for the
facts, types.ts for the shapes, index.ts for the typed entry,
and small TypeScript modules for anything that is interface vocabulary rather than
a demo fact (catalog.ts, badges.ts, *-vocab.ts).
It is one world — a small homewares shop — rather than eight unrelated sample industries, which is what lets a figure on the home dashboard be the same figure on the order it came from.
Three rules it follows
- No random values at render time. A screenshot taken twice is the same screenshot.
- Dates are relative to the world's own "now" (
world.now,world.today), so the demo does not age into "3 years ago". - No page hardcodes a figure a demo file owns.
scripts/check-demo.mjsfails the build on it.
Replacing it with your data
Delete src/data/demo/ and let TypeScript walk you through it — every page
that read from it becomes a type error, which is the complete list of the call sites you
have to wire up, with no chance of missing one. Then:
- Keep
types.tsas the shape your API layer has to satisfy, or replace it with types generated from your schema. - Put your fetching wherever you prefer — a hook, a loader, a query client. Nothing in the template dictates it, because nothing in the template fetches.
- The vocabulary modules (status labels, segment names, payment method names) are interface copy, not data. Keep them.
Theming & design tokens
This is the section to read if you want the product to look like yours. Everything
visual is a CSS custom property in src/styles/tokens/, in four layers:
| Layer | File | What it holds |
|---|---|---|
| 1. Primitives | _primitives.css | The raw ramps — four color families of 18 values each, plus spacing, radii, type sizes, durations, easings. Literal hex lives here and nowhere else. |
| 2. Roles | _roles.css | Semantic names components consume: --tg-bg, --tg-surface, --tg-ink, --tg-accent. Built from the scheme scale, never from primitives directly. |
| 3. Variants | _themes.css | The [data-tg-*] blocks — dark, the five schemes, density, island, contrast. |
| 4. Recipes | _recipes.css | The surfaces: .tg-tile and its tones, .tg-inset, tracks, scrollbar pairs. |
The roles you will actually touch
| Token | Is |
|---|---|
--tg-bg / --tg-bg-2 | The canvas, and the deeper canvas. |
--tg-on-bg / --tg-on-bg-2 | Text on the canvas — primary and secondary. |
--tg-surface / --tg-surface-2 / --tg-inset | The three tile steps. |
--tg-ink / --tg-ink-2 / --tg-ink-3 | The three text steps on a tile. |
--tg-primary / --tg-primary-ink | The brand fill and the text on it. |
--tg-accent / --tg-accent-ink / --tg-accent-mark | The hero: fill, text on it, and the mark tone for charts. |
--tg-good, --tg-bad, --tg-warn, --tg-info | The signals, each with -soft and -mark companions. |
--tg-focus | The focus ring. It changes with the surface — see the warning below. |
--tg-line, --tg-edge | The 2px ink line, and the quieter edge a bordered control wears. |
Changing the look
Just the brand color
Change the green family's deep pair in _primitives.css. The canvas, the
primary button, the active nav item, the info chip and the green tile all follow,
because all five are the same role.
--tg-green-700: #0e4a3a; /* the canvas and the primary */
--tg-green-800: #0a3a2d; /* deeper canvas, primary pressed */
The whole palette
See Color schemes — a scheme is 18 values, and swapping them re-colors everything.
Shape
--tg-radius-xl: 28px; /* tiles */
--tg-radius-md: 18px; /* inner elements */
--tg-radius-pill: 999px; /* every control */
Lower --tg-radius-xl for a tighter product; the compact density already
takes it to 20px. Do not lower --tg-radius-pill — the pill is the
product's one consistent control shape, and rounding it off makes the whole thing
generic.
Two traps, both of which have bitten this codebase.
1. A custom property resolves its var()s on the element that declares
it. So --tg-edge: color-mix(…, var(--tg-line), …) declared on
:root freezes to the root's line color and does not follow
--tg-line down the tree. Every block that restates --tg-line
must restate --tg-edge with it. The comments in _roles.css
mark every place this matters.
2. The focus ring belongs to the surface, not to the theme. Butter on bone is
1.26:1, so inside a tile the ring is ink; on an ink tile in dark mode it is butter.
If you add a surface that paints its own background, add it to the ring list in
_roles.css or controls inside it will keep the canvas's ring.
The rule the build enforces
scripts/check-tokens.mjs fails on a literal color, radius or shadow anywhere
in src/ outside styles/tokens/, and on any Tailwind palette or
arbitrary-color utility. It is there so that "change the brand color" stays a one-file
job for as long as you own the code.
Color schemes & dark mode
Five schemes
A scheme is a whole palette, not a background: the canvas, the tiles, the ink, the muted text, the primary, the info chip, the charts and the three colored tiles all move together.
The signals keep their jobs and change their hue: the hero tile is butter on green, honey on cocoa, gold on navy and amber on plum. Each is stated at green's own lightness and chroma, so "hero", "good" and "urgent" read at the same strength on every scheme and only the color changes.
Calm is the answer to canvas fatigue: green's palette with the canvas turned bone. Buttons and the active nav item stay green, in both modes.
Adding a sixth scheme
- Copy a block in
_themes.cssand change its 18 values — 13 tones (a deep pair, a light tone, three tints, three shades, four mutes) and 5 signals (the hero accent, a good pair, an urgent pair). - Add the name to
REGISTRY.scheme.valuesinsrc/lib/theme.ts. - Add its label to the customizer's
LABELSmap. - Run the contrast pass:
npm run check:a11y -- http://localhost:5193 --schemes yourscheme.
That last step is not optional if you care about the accessibility claim. The 18 values are load-bearing — the muted text steps in particular are tuned per family, because the contrast of a bone tint on a deep canvas is not the contrast of a cream tint on a brown one.
Dark mode is designed, not derived
It is not an inversion and not a filter. The canvas stays exactly where it is and the tiles become ink with bone text, so the product still reads as itself. Every role is restated for dark by hand, and three of them change meaning:
- The chart marks swap to the light tints — on an ink tile a tint is the brighter, better mark, where on bone it was invisible.
- The focus ring becomes butter inside tiles, where in light mode it is ink.
- The colored tiles stay light surfaces in both themes, so anything on them keeps ink text.
Light, dark and system
data-tg-theme holds the resolved theme; the stored key holds the raw choice,
which may be system. When it is, the runtime follows
prefers-color-scheme live — change your OS appearance with the app open and
it follows without a reload.
The anti-flash script
The small IIFE at the very top of index.html reads the stored axes and writes
every attribute on <html> before the first stylesheet, so a
returning visitor never sees a flash of the wrong theme. It must stay first, it never
throws (storage can be blocked), and its key list is diffed against
lib/theme.ts by check-manifest.mjs so the two cannot drift.
Storage and the schema guard
Settings live under tg: keys in localStorage, with
tg:schema holding a version number. If the stored version is not the one the
code knows, the whole tg: namespace is wiped rather than half-read.
If you change the shape of a stored value, bump tg:schema. And if
you run the app at more than one URL on the same origin — say a marketing page and the
app — both must write the same schema number, or each will see the other's as unknown
and wipe the user's settings on every hop between them.
Typography
| Role | Family | Used for |
|---|---|---|
--tg-font-display | Bricolage Grotesque 700–800 | Headings, tile titles, and every numeral |
--tg-font-body | Figtree 400–700 | All text, 14px default |
--tg-font-mono | System mono stack | Code, IDs, the customizer's snippet |
Bricolage Grotesque is a variable font with an optical-size axis, and it is requested
with opsz on — which is why the giant KPI numerals hold together at 76px and
the same family still reads at 22px in a tile head. Tracking is tightened as size goes
up: -0.045em for numerals, -0.03em for display,
-0.02em for titles.
Swapping the fonts
- Change the Google Fonts
<link>inindex.html. - Change the two family tokens in
tokens/_typography.css.
That is all — no component names a font.
Check the numerals after a swap. The KPI tiles are the product's signature and
they depend on a display face that is heavy, tight and tabular at very large sizes. A
lighter or wider replacement will not simply look different; it will change the
balance of every dashboard. If your face has no optical-size axis, drop the
opsz part of the request rather than leaving a dead axis in the URL.
Self-hosting
Both fonts are SIL Open Font License, so you may self-host them. Put the files under
public/fonts/, replace the <link> in
index.html with @font-face rules, and keep
font-display: swap.
The six axes
Every configurable thing is one attribute on <html>, persisted under
one key. The default writes no attribute and no key — so a fresh browser and a
reset browser are the same thing, and the stored state is only ever what the user
actually changed.
| Axis | Attribute | Values | Default |
|---|---|---|---|
| Mode | data-tg-theme | light · dark · system | light |
| Color scheme | data-tg-scheme | green · cocoa · navy · plum · calm | green |
| Density | data-tg-density | comfortable · compact | comfortable |
| Island | data-tg-island | expanded · rail | expanded |
| Direction | dir | ltr · rtl | ltr |
| Motion | data-tg-motion | full · reduced | full |
Reading an axis in a component
import { useAxis } from '@/hooks/useAxis';
const density = useAxis('density'); // re-renders when it changes
Setting one
import { set } from '@/lib/theme';
set('scheme', 'navy'); // attribute + storage + a tg:change event on window
The customizer
src/components/shell/Customizer.tsx is the panel that drives all six. It is
one file with no other component depending on it — delete it and remove its
trigger from the island, and the product keeps every axis, just without the UI to change
them at runtime.
Its Copy settings button gives you the exact attribute set for your current configuration, ready to paste — which is the easy road to the next section.
Baking in a default
Most buyers want the product to open in their configuration rather than in Tangram's. There are two ways, and they mean different things.
1. A different starting point (most of the time this one)
Put the attributes on <html> in index.html. The
anti-flash script only overrides an attribute for which the user has a stored choice, so
this becomes the default for everyone who has not picked something else:
<html lang="en-US" data-tg-theme="dark" data-tg-scheme="navy" data-tg-density="compact">
2. A hard default, overriding returning visitors
If you have already shipped and want to move everyone, bump the schema version in the
anti-flash script. A version the browser does not recognize wipes the whole
tg: namespace, so every visitor falls back to your new default exactly once:
/* in the anti-flash IIFE in index.html */
LS.setItem('tg:schema', '2'); /* was '1' */
This throws away the user's saved preferences — scheme, mode, density, the lot. That is the point of it, but it is not a thing to do casually, and never do it on every deploy.
3. Removing an axis entirely
If you do not want users changing something, delete its entry from
REGISTRY in src/lib/theme.ts and its row from the customizer.
Remove its key from the KEYS list in the anti-flash script at the same time
— check-manifest.mjs will fail the build if you forget, which is the point.
RTL & localization
RTL
The layout flips through CSS logical properties — margin-inline-start
rather than margin-left, and so on — not through a mirrored stylesheet. So
there is no second CSS build to keep in step, and no flipped copy to forget to update.
<html lang="ar" dir="rtl">
Or toggle Direction in the customizer to check a page both ways while you work. The few
things that must not flip — a chart's time axis, a progress track's origin — are
handled with explicit [dir='rtl'] rules.
Language
The template ships in US English, and there is no i18n library in it — adding one is a
decision about your stack (react-i18next, FormatJS, Lingui) that a template should not
make for you. Interface copy lives in the components and in the
*-vocab.ts modules, which is where you would swap in a translation function.
Numbers, dates and currency
All of it goes through src/lib/format.ts, which uses
Intl with a fixed en-US locale. Change that one constant, or
thread your user's locale through it, and every figure, date and price in the product
follows.
Accessibility
What the product does, so you know what you are inheriting:
- Contrast is measured, not estimated. Every role pair that ships has a recorded ratio in both themes and all five schemes, at AA.
- Keyboard operable throughout — navigation, the command palette (⌘K), dialogs and drawers trap focus and restore it on close.
- A visible focus ring on everything, and it changes color with the surface it lands on, because this product has no shadows or hairlines to fall back on.
- Non-text contrast — control boundaries and focus rings are held to 3:1 (WCAG 1.4.11), which is the rule an unlined design is most exposed to.
- Reduced motion is honored via the OS setting and the product's own axis.
- Charts carry an accessible name —
labelis a required prop, not an optional one. - A skip link, landmark regions and an ordered heading outline on every page.
npm run check:a11y runs axe-core over every route in both themes and then
does its own non-text contrast pass. See Quality gates.
The thing to watch when you extend it. With no borders and no shadows, a control is told from its background by color alone. If you add a variant, check its boundary and its focus ring against what it actually sits on — not against the page background. A ghost button that looks fine on a bone tile can be invisible on a butter one.
Quality gates
npm run check is the gate, and it is worth keeping in your own pipeline
rather than deleting once you own the code — each script encodes a rule that is easy to
break by accident and expensive to find by eye.
| Script | Refuses |
|---|---|
check-tokens.mjs | A literal color, radius or shadow outside styles/tokens/, and any Tailwind palette or arbitrary-color utility. |
check-manifest.mjs | The manifest, the page files and the route ledger disagreeing; a page breaking the page contract; a reachable placeholder. |
check-motion.mjs | An entrance animation on sticky or fixed chrome, and transitions of layout-thrashing properties. |
check-demo.mjs | A page repeating a figure, name or date that a demo file owns. |
check-size.mjs | CSS, initial JS or a lazy page chunk over budget. Needs dist/. |
The browser passes
Two scripts drive a real browser. playwright-core is deliberately
not a dependency — you should not have to download a browser to build an admin
template — so point PLAYWRIGHT_CORE at an installed copy:
mkdir -p /tmp/pw && (cd /tmp/pw && npm i --no-save playwright-core)
export PLAYWRIGHT_CORE=/tmp/pw/node_modules/playwright-core/index.mjs
npm run build && npx vite preview --port 5193 &
node scripts/smoke.mjs http://localhost:5193 # every route: exceptions, console errors, blank renders
npm run check:a11y -- http://localhost:5193 # axe + the non-text contrast pass
npm run check:a11y -- http://localhost:5193 --quick # the design-kit pages only — the fast loop
The smoke pass catches a blank chart, because a chart that cannot draw reports to the console. It makes no visual judgment — that is still your eyes.
Deployment
npm run build # → dist/
dist/ is a static single-page app: HTML, hashed assets, a favicon. Any
static host serves it. There is one rule you must apply, and one optional one.
The rewrite (required)
Unknown paths have to fall back to index.html, or a refresh on
/orders/list is a 404 from your server rather than a route in your app.
| Host | Rule |
|---|---|
| Netlify | /* /index.html 200 in _redirects |
| Vercel | { "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] } |
| Nginx | try_files $uri $uri/ /index.html; |
| Apache | FallbackResource /index.html |
| S3 + CloudFront | Error document index.html, 404 and 403 → 200 |
Caching (optional, but do it)
Asset filenames are content-hashed, so dist/assets/* can take a one-year
immutable cache. index.html must not be cached that way, or visitors
keep loading an old app that points at assets you have deleted.
A subfolder
npx vite build --base=/admin/
The router derives its basename from that same value, so nothing else changes.
Test the front door, not just a deep link. With a base of /admin/,
the router's basename is /admin — without the trailing slash. If your host
redirects /admin/ to /admin and the basename still has its
slash, deep links keep working and only the front door renders empty: status
200, correct assets, an empty #root, and React Router reporting it as a
console warning rather than an error. It is the one deployment bug that passes every
obvious check. Verify by asserting that #root has children.
Plugins & credits
Runtime libraries
| Library | Version | License |
|---|---|---|
| React, React DOM | 19.2 | MIT |
| React Router | 8.3 | MIT |
| ApexCharts | 7.1 | MIT |
| Leaflet | 1.9 | BSD-2-Clause |
Floating UI (@floating-ui/react) | 0.27 | MIT |
Tabler Icons (@tabler/icons-react) | 3.46 | MIT |
Build tooling
| Tool | Version | License |
|---|---|---|
| Vite (Rolldown) | 8.2 | MIT |
| TypeScript | 5.9 | Apache-2.0 |
| Tailwind CSS | 4.3 | MIT |
| ESLint, Stylelint, Prettier | current | MIT |
Fonts
- Bricolage Grotesque by Mathieu Triay — SIL Open Font License 1.1
- Figtree by Erik Kennedy — SIL Open Font License 1.1
Map tiles
The Live view uses OpenStreetMap tiles, © OpenStreetMap contributors, under the Open Database License (ODbL). Attribution is required and the control is on by default. For production volume, move to a commercial tile provider.
Imagery
None is bundled. Every illustration, avatar, product thumbnail and the Tangram mark are original SVGs made for this template, so there is nothing here you have to license separately, swap out, or strip before you ship.
Your license
Tangram is sold under the standard Envato Regular and Extended licenses. In short: the Regular license covers one end product that is free for its end users; the Extended license covers one end product that users are charged for. The authoritative text is on your ThemeForest downloads page.
FAQ
Is there an HTML, Vue or Next version?
No. This is one React 19 project, deliberately. A bundle of frameworks means seven of them are dead weight in your download and none of them get the attention one would. If a version for another stack exists it will be a separate item built properly for that stack, not a translation of this one.
Do I need to know Tailwind?
Not to re-theme it. The look is CSS custom properties in
src/styles/tokens/; Tailwind handles layout utilities. See
Theming & tokens.
Can I use it with Next.js?
The components are plain React and will move, but this is a client-rendered SPA: the router, the anti-flash script and the theme runtime all assume a browser. Budget real time for the port — it is not a copy-paste — and expect to rewrite the routing layer entirely.
Is there a backend?
No — see Demo data for how the data layer is arranged and how to replace it.
Can I remove the customizer before shipping?
Yes. It is one file and nothing depends on it. Delete
src/components/shell/Customizer.tsx and its trigger; the axes all keep
working, you just set them in index.html instead.
Can I use it for a client project?
Yes — one license per end product. If the client's users will be charged to access it, that is the Extended license.
Can I resell or redistribute it?
No. Neither the template nor a derivative of it may be redistributed as a template, theme or UI kit. You ship end products built with it.
Why is the bundle so small for 84 pages?
Every page is a lazy chunk, ApexCharts and Leaflet are dynamic imports, and icons are imported per icon. A page with no chart never loads charting code.
Support & updates
What support covers
- Answering questions about how the template works.
- Fixing defects — something in here that is broken or does not do what this manual says.
- Keeping dependencies current and compatible.
What it does not cover
- Custom development, new features or design work for your project.
- Installing it on your server or debugging your hosting.
- Third-party libraries you add, and bugs in your own code.
Getting help
Use the Support tab on the item's ThemeForest page. It helps enormously to
include: what you expected, what happened, the page or component, your Node version, and
the exact output of npm run check if it is failing.
Updates
Updates are free for the life of the item. Re-download from your ThemeForest Downloads page — there is no auto-update. If you have modified the source, diff rather than overwrite; keeping your own work in version control from day one makes that a five-minute job instead of an afternoon.
Changelog
1.0.0
First release.
- 84 routes over one demo world. Seven dashboards, twenty-six lists, five records, nine forms and editors, eight full-height apps, three standing pages, seven auth screens, six error pages and thirteen design-kit pages.
- The flat bento design system. No borders, no hairlines, no shadows — depth is color blocks and 2px ink lines. Bricolage Grotesque numerals, Figtree text at 14px, a full pill for every control.
- Five color schemes, two themes, two densities. Green, cocoa, navy, plum and a bone-on-bone calm mode; a designed dark theme that keeps the canvas; comfortable and compact row heights; an expanded or rail island.
- Full right-to-left support through logical properties, with no second stylesheet.
- Charts — thirteen ApexCharts types behind one lazy wrapper, four in-house SVG charts, all re-coloring live with the scheme.
- Quality gates — five source check scripts, a route smoke pass and an accessibility pass with its own non-text contrast check.
Tangram 1.0.0 — a React admin template by Avagon.