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.

Version 1.0.0 React 19 · TypeScript · Vite 84 routes by Avagon

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

AreaCoverage
Routes84, every one a real page — there is no placeholder behind a nav item
Dashboards7 — home, sales, traffic, product performance, customer cohorts, payments, loyalty
Full-height apps8 — shipping queue, customer inbox, social planner, media library, team inbox, chat, calendar, notes
Lists and records26 list pages and 5 record pages, all on shared archetypes
Forms9 form and editor pages, including a campaign builder and a post editor
Auth and errors7 auth screens and 6 error pages
Design kit13 pages documenting the components inside the product itself
Theming2 themes · 5 color schemes · 2 densities · 2 island modes · LTR/RTL · motion axis
Charts13 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

ToolVersionNotes
Node.js^20.19 or >=22.12The line is pinned in .nvmrc. Vite 8 requires it.
npm10 or newerShips with those Node versions. pnpm and yarn work; the lockfile is npm's.
A browserAny current oneChrome, 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

CommandWhat it does
npm run devDev server with hot reload.
npm run buildType-checks the project references, then builds into dist/.
npm run previewServes dist/ locally, so you can check the real build.
npm run typechecktsc -b --noEmit.
npm run lintESLint over the repo — flat config, type-aware.
npm run lint:cssStylelint over src/**/*.css.
npm run formatPrettier over the tree. format:check only reports.
npm run checkThe 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

  1. styles/tokens/ — every color, radius and duration in the product. Nothing outside this folder states a literal color.
  2. data/nav-manifest.json — every route. The island, the breadcrumbs, the command palette and the router all read it.
  3. components/ — the kit. The 13 design-kit pages under pages/ui/ document it from inside the app.
  4. 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:

  1. @import "tailwindcss"
  2. tokens/_primitives.css — the raw ramps
  3. tokens/_typography.css — families and metrics
  4. tokens/_roles.css — semantic roles, the default theme
  5. tokens/_themes.css — the [data-tg-*] variants
  6. tokens/_motion.css and tokens/_charts.css
  7. tokens/_recipes.css — the surface recipes (.tg-tile and friends)
  8. 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"]
}
FieldMeaning
slugThe URL path and the registry key.
titleShown in the island, the breadcrumb, the palette and <title>.
kindThe archetype: dashboard, list, detail, form, app, page, kit, auth, error.
shellisland (the sidebar), focus (full-height apps), bare (auth and errors).
badgeOptional. A key resolved through data/demo/badges.ts, never a constant.
keywordsOptional. 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(), and Link/Button take a manifest slug in to;
  • does not import a context — the toast bus (lib/toast.ts) and useAxis cover what a page needs;
  • does not call fetch — every figure comes from src/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

kindBuild it on
listListPage — toolbar, filters, Table, pagination, empty state
detailDetailPage — a record header, tabs, a bento of tiles
formFormPage — sectioned Fields, a sticky action bar
appAppFrame + Pane — a full-height two or three pane layout
dashboard / pageA tg-bento grid of Tile and Kpi
authAuthCard
errorErrorPage

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';
GroupComponents
ActionsButton, Dropdown (+ MenuItem, MenuLabel, MenuSeparator), Segmented, Link, NavLink
FormsField, Fields, Input, SearchInput, Textarea, Select, FilterSelect, Checkbox, Radio, Switch, Stepper
SurfacesTile, TileHead, Dialog, Drawer, Tabs, TabPanel
DataTable, Pagination, Dl, Track, Stars, MiniCalendar, Steps
StatusStatus, Chip, Count, Delta, Alert, Skeleton, SkeletonRows, EmptyState
MediaAvatar, AvatarStack, Thumb, Icon
OverlayTooltip, 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>
PropTypeNotes
tonebone | butter | mint | coral | green | insetDefault bone. See the tone rules below.
span1–12Bento column span.
tallbooleanSpan two bento rows.
titleReactNodeRenders a TileHead.
more{ label, to?, onClick? }The trailing link in the head. to is a manifest slug.
actionsReactNodeControls in the head.
assection | article | div | liDefault 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 */}
PropValues
variantsurface (default), primary, accent, danger, ink, soft, line, ghost, quiet
sizesm | md | lg
icon, iconEndA Tabler icon component
iconOnlySquare pill; the children become the accessible label
loading, selected, block, round, dotBooleans
toA 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"
/>
PropNotes
typearea · line · bar · column · donut · pie · radialBar · polarArea · heatmap · radar · treemap · candlestick · scatter
seriesApexCharts' own series shape.
labelRequired. The chart renders with role="img" and this as its aria-label.
categories, labelsAxis categories or slice labels.
color1–6 — force one series color from the chart palette.
stacked, horizontal, sparkline, legend, tooltip, values, trackBooleans.
unit, centerLabel, centerValue, curve, heightFormatting 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.mjs fails 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:

  1. Keep types.ts as the shape your API layer has to satisfy, or replace it with types generated from your schema.
  2. 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.
  3. 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:

LayerFileWhat it holds
1. Primitives_primitives.cssThe 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.cssSemantic names components consume: --tg-bg, --tg-surface, --tg-ink, --tg-accent. Built from the scheme scale, never from primitives directly.
3. Variants_themes.cssThe [data-tg-*] blocks — dark, the five schemes, density, island, contrast.
4. Recipes_recipes.cssThe surfaces: .tg-tile and its tones, .tg-inset, tracks, scrollbar pairs.

The roles you will actually touch

TokenIs
--tg-bg / --tg-bg-2The canvas, and the deeper canvas.
--tg-on-bg / --tg-on-bg-2Text on the canvas — primary and secondary.
--tg-surface / --tg-surface-2 / --tg-insetThe three tile steps.
--tg-ink / --tg-ink-2 / --tg-ink-3The three text steps on a tile.
--tg-primary / --tg-primary-inkThe brand fill and the text on it.
--tg-accent / --tg-accent-ink / --tg-accent-markThe hero: fill, text on it, and the mark tone for charts.
--tg-good, --tg-bad, --tg-warn, --tg-infoThe signals, each with -soft and -mark companions.
--tg-focusThe focus ring. It changes with the surface — see the warning below.
--tg-line, --tg-edgeThe 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.

Green#0E4A3A · the brand
Cocoa#4A2E22 · warm brown
Navy#17305A · cool blue
Plum#4A2350 · purple
Calm#E9E3D5 · bone canvas

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

  1. Copy a block in _themes.css and 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).
  2. Add the name to REGISTRY.scheme.values in src/lib/theme.ts.
  3. Add its label to the customizer's LABELS map.
  4. 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

RoleFamilyUsed for
--tg-font-displayBricolage Grotesque 700–800Headings, tile titles, and every numeral
--tg-font-bodyFigtree 400–700All text, 14px default
--tg-font-monoSystem mono stackCode, 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

  1. Change the Google Fonts <link> in index.html.
  2. 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.

AxisAttributeValuesDefault
Modedata-tg-themelight · dark · systemlight
Color schemedata-tg-schemegreen · cocoa · navy · plum · calmgreen
Densitydata-tg-densitycomfortable · compactcomfortable
Islanddata-tg-islandexpanded · railexpanded
Directiondirltr · rtlltr
Motiondata-tg-motionfull · reducedfull

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 — label is 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.

ScriptRefuses
check-tokens.mjsA literal color, radius or shadow outside styles/tokens/, and any Tailwind palette or arbitrary-color utility.
check-manifest.mjsThe manifest, the page files and the route ledger disagreeing; a page breaking the page contract; a reachable placeholder.
check-motion.mjsAn entrance animation on sticky or fixed chrome, and transitions of layout-thrashing properties.
check-demo.mjsA page repeating a figure, name or date that a demo file owns.
check-size.mjsCSS, 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.

HostRule
Netlify/* /index.html 200 in _redirects
Vercel{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
Nginxtry_files $uri $uri/ /index.html;
ApacheFallbackResource /index.html
S3 + CloudFrontError 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

LibraryVersionLicense
React, React DOM19.2MIT
React Router8.3MIT
ApexCharts7.1MIT
Leaflet1.9BSD-2-Clause
Floating UI (@floating-ui/react)0.27MIT
Tabler Icons (@tabler/icons-react)3.46MIT

Build tooling

ToolVersionLicense
Vite (Rolldown)8.2MIT
TypeScript5.9Apache-2.0
Tailwind CSS4.3MIT
ESLint, Stylelint, PrettiercurrentMIT

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.