Design system¶
indah ships one look, called Studio, and styles every component through a small set of named design tokens (CSS custom properties). You never restyle a component. You change a token, and every component that uses it follows. This page is the contract: what the tokens are, what each is for, and how to override them.
The role names follow Material Design 3 (primary, on-primary, container, outline, surface), because that vocabulary is well understood. The values and the look are indah's own: warm porcelain, bougainvillea magenta, deep teal.
Overriding tokens¶
indah.create_app(
theme=indah.Theme(
light={"primary": "#1f6feb"},
dark={"primary": "#79b8ff"},
radius="8px",
font="system-ui, sans-serif",
),
density="compact",
)
Only the colour roles below can be overridden, per scheme (light and dark). radius and
font apply to both. An unknown token name raises an error, so a typo cannot pass silently.
If you override a *-container role, also override its on-*-container partner so the text
stays readable. The Bunga logo is brand identity, not a theme role: a theme never recolours
it. To use your own mark, pass logo=. A mark that vanishes on one background needs a pair:
logo=(light_mark, dark_mark) shows the one that matches indah's light/dark toggle (an SVG that
relies on currentColor does not work, since the logo is shown in an <img>).
Try it live. Nothing downloads until you press Run it.
x- tokens, radius, font, density and frame width.import indah
theme = indah.Theme(
light={"primary": "#d6336c", "primary-container": "#ffe3ec"},
dark={"primary": "#ff8fb3", "primary-container": "#5c1630"},
radius="2px",
font="Georgia, serif",
# the app's own tokens: named x-<name>, read as var(--x-brand) in your CSS
extra_light={"x-brand": "#d6336c"},
extra_dark={"x-brand": "#ff8fb3"},
)
app = indah.create_app(
session_factory=build,
theme=theme,
density="compact", # or "comfortable" (the default): table row height and padding
max_width="720px", # the page frame; "none" for full width
) Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/layout/theming)
Your own tokens¶
An app can add roles the core set does not have, such as a row-hover fill or a status colour.
Name them x-<name> and pass them per scheme; they are emitted as --x-<name> for your CSS
to read (a custom component's styles, for example).
indah.Theme(
extra_light={"x-brand": "#b5296b", "x-row-hover": "rgba(0, 0, 0, 0.04)"},
extra_dark={"x-brand": "#ff7ab6", "x-row-hover": "rgba(255, 255, 255, 0.06)"},
)
Define a token in both schemes if it should differ, because one set only in extra_dark has no
value in light. Values are checked like any other theme value. The core roles stay a closed
set: Theme(light={"x-brand": ...}) raises, and so does an extra that does not start x-.
The page frame is 1080px wide at most. create_app(max_width="none") lets it fill the window
(a dashboard with a left rail wants this), and max_width="1400px" or "90rem" sets another
cap. Anything else raises an error. It is applied as the --page-max token.
The browser tab reads "<title> - indah" when you pass title=. Pass title_suffix="" for
the exact title (title="semula", title_suffix="" gives <title>semula</title>), or any other
suffix such as " | Semula Ops".
Colour roles¶
Every role is defined for light and dark. Use a role by its job, never by its hex value.
| Token | Job |
|---|---|
bg |
the page background |
surface |
cards, panels, dialogs, popovers |
surface-2 |
inputs, table headers, hover fill, quiet wells |
surface-3 |
pressed or selected fill, tooltips, the strongest neutral |
text |
primary text |
muted |
secondary text, labels, placeholders |
outline |
borders of inputs, buttons and cards |
outline-soft |
hairlines: table rules, dividers |
primary / on-primary |
the main action (filled button) and text on it |
primary-container / on-primary-container |
a soft primary fill (selected row, info badge) and its text |
secondary / on-secondary |
the second accent and text on it |
secondary-container / on-secondary-container |
a soft secondary fill (tonal button) and its text |
ok, warn, err |
status colours, used for text and for tinted badges |
shadow |
the one elevation shadow |
The rules:
- Every filled role has an
on-partner. Puton-primarytext onprimary, nevertext. - Status is never colour alone: pair it with a word or an icon.
- A new component uses existing roles. Adding a role is a design decision, recorded in an ADR, not a one-off in a component.
Shape, type, spacing¶
| Token | Value | Use |
|---|---|---|
radius |
14px | cards, dialogs, panels |
radius-sm |
10px | inputs, buttons inside dense UI, images, tooltips |
radius-pill |
24px | chips and pills |
radius-control |
radius-pill |
buttons and badges; Theme(radius=) sets it, so a squarer theme squares them too |
font-display |
Bricolage Grotesque | headings and the wordmark |
font-body |
IBM Plex Sans | body text and controls |
font-mono |
IBM Plex Mono | ids, numbers, table headers, code |
Body text is 15px. Small text steps down through 0.95, 0.85, 0.8 and 0.75rem, and no
component should invent a size outside that ladder. Spacing is in rem, on a quarter-rem
grid (0.25, 0.5, 0.75, 1, 1.5).
Plain Text has six roles (ADR-0040), all on that ladder:
variant |
Size, weight | Colour |
|---|---|---|
body (default) |
0.95rem, 400, line height 1.55 | --text |
title |
1.3rem, 600 | --text |
label |
0.85rem, 500 | --muted |
caption |
0.78rem, 400 | --muted |
mono |
0.88rem, 400, --font-mono |
--text |
readout |
1.4rem, 600, tabular numbers | --text |
Density¶
Tables read four tokens: --row-h, --cell-pad-y, --cell-pad-x and --table-font.
density="comfortable" (the default) and "compact" swap the whole set, for the app or for
one Table(density=).
Interaction states¶
| State | Treatment |
|---|---|
| hover | a subtle fill change (surface-2) or brightness on filled buttons |
| pressed | the button moves 1px down |
| focus (keyboard) | a 2px primary outline, offset from the control. Inputs get a soft primary ring |
| disabled | muted text at reduced opacity. Only unavailable download links use it today; buttons have no disabled state yet |
| selected | primary-container fill |
Focus is always visible for keyboard users, and every control is reachable with the keyboard.
Layers¶
Overlapping surfaces stack in a fixed order, so two components never fight over z-index.
| Layer | z-index |
|---|---|
| sticky table header | 2 |
| column menu | 5 |
| modal and drawer | 50 |
| toasts (bottom centre) | 60 |
| tooltips | 70 |
Modals and drawers are centred and right-edge, and toasts sit at the bottom centre. A tooltip opens at the pointer on hover and under the control on keyboard focus.
Motion¶
Transitions are short (0.15s) and limited to colour, border and a 1px press. Anything that
loops, such as the spinner, slows down under prefers-reduced-motion.
What this is not¶
indah is not stock Material 3. It borrows the role vocabulary and the discipline (every colour has a job, every surface has an elevation, every state is defined) and leaves out the rest: there is no dynamic colour, no state-layer opacity scale and no type-scale tokens. If that changes, it changes here first.