TerraUI v0.1.1

Theming

Start from the default theme and override what your brand needs with createTheme. Every token, the colour pairs that must pass WCAG AA, and a custom theme.

Override the default

TerraUI ships one theme, Electric, as defaultLight and defaultDark. To match your brand, start from it and change a few tokens with createTheme:

import { createTheme, defaultDark, defaultLight } from "@terratcg/ui";

export const brandLight = createTheme(defaultLight, {
  colors: { accent: "#7A2BD9", accentEdge: "#4E1A8C", accentSoft: "#EFE3FC", onAccentSoft: "#5A1FA3" },
  shape: { radiusMd: 22 },
});

export const brandDark = createTheme(defaultDark, {
  colors: { accent: "#B58CFF", accentEdge: "#7A4FCC", accentSoft: "#2A1D45", onAccentSoft: "#CDB2FF", onAccent: "#1A0B33" },
  shape: { radiusMd: 22 },
});

createTheme(base, overrides) deep-merges your overrides over base and returns a complete Theme. Every token you leave out keeps the base value. Neither input is changed.

It derives nothing: a new accent does not compute its accentEdge, its soft fill or the text on it. Change a colour together with the tokens that sit beside it (its edge, its soft fill, the text on it), then check the pairs under Contrast pairs.

A Theme is a plain object, so a complete hand-written theme works too. Type it as Theme and TypeScript lists every token you still owe. That path is for when you control every value, depth included.

Light and dark

Your app picks the appearance and owns any stored preference. Give ThemeProvider one theme at a time:

import { ThemeProvider } from "@terratcg/ui";
import { useColorScheme } from "react-native";

import { brandDark, brandLight } from "./theme";

export function Root({ children }: { children: React.ReactNode }) {
  const dark = useColorScheme() === "dark";
  return <ThemeProvider theme={dark ? brandDark : brandLight}>{children}</ThemeProvider>;
}

Build the light and dark themes from defaultLight and defaultDark respectively: each carries its own depth, tuned for its ground. See ThemeProvider and feedback.

A custom theme

Tide is a sample theme made with createTheme: coral actions on sand and deep navy, rounder corners, longer travel and heavier labels. The same components take on a different identity. Tide is not exported; copy what you need.

Tide

The same Card, Switch, Progress and Buttons in Tide, a custom theme: coral keys on sand, rounder and deeper.

iPhone 17 Pro · iOS 27
The same Card, Switch, Progress and Buttons in Tide, a custom theme: coral keys on sand, rounder and deeper.The same Card, Switch, Progress and Buttons in Tide, a custom theme: coral keys on sand, rounder and deeper.
import {
  Button,
  ButtonText,
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
  Progress,
  RowContent,
  RowTitle,
  SwitchRow,
  ThemeProvider,
  useTheme,
} from "@terratcg/ui";
import { useState } from "react";
import { View } from "react-native";

import { tideDark, tideLight } from "../../themes/tide";

export default function TideTheme() {
  // Follow the surrounding appearance; an app would read useColorScheme().
  const tide = useTheme().scheme === "dark" ? tideDark : tideLight;
  const [reminders, setReminders] = useState(true);
  return (
    <ThemeProvider theme={tide}>
      <View style={{ backgroundColor: tide.colors.background, borderRadius: 28, padding: 20, width: 340, maxWidth: "100%" }}>
        <Card>
          <CardHeader>
            <CardTitle>Morning swim</CardTitle>
            <CardDescription>Three lengths left before the pool closes.</CardDescription>
          </CardHeader>
          <CardContent style={{ gap: 16 }}>
            <Progress value={0.7} accessibilityLabel="Swim progress" />
            <SwitchRow value={reminders} onValueChange={setReminders} accessibilityLabel="Reminders">
              <RowContent>
                <RowTitle>Reminders</RowTitle>
              </RowContent>
            </SwitchRow>
          </CardContent>
          <CardFooter style={{ gap: 12 }}>
            <Button variant="secondary">
              <ButtonText>Later</ButtonText>
            </Button>
            <Button>
              <ButtonText>Start</ButtonText>
            </Button>
          </CardFooter>
        </Card>
      </View>
    </ThemeProvider>
  );
}

Safe to change

Colours, typography, spacing, shape and motion change how TerraUI looks without changing how its material works. Every value below is Electric's.

name and scheme identify the theme: a family name of your choosing, and "light" or "dark".

Colours

Every control reads its colours from these semantic tokens, never from literals.

TokenLightDarkRole
colors.background#F6F5F2#171613Page ground.
colors.surface#FFFFFF#22201CRaised content surface: cards, panels.
colors.bar#FFFFFF#22201CThe chrome bars (tab bar deck, header bar): a shell that may differ from surface per scheme.
colors.surfaceMuted#EEECE7#1B1A17Recessed wells and tracks. In dark, keep it below surface so a well reads as lower.
colors.border#DBD8D1#3A3731Hairline dividers and outline strokes.
colors.ink#1D1B17#F6F4EFPrimary text.
colors.inkSecondary#5F5B54#BFBAB1Supporting text.
colors.inkMuted#69655E#969188Placeholders, timestamps, denominators.
colors.accent#0A5CF5#3F8CFFThe single UI accent: primary actions, selection, progress.
colors.accentEdge#003FB3#1C5FD6Lower edge of an accent control.
colors.accentSoft#DEE9FF#14284ASoft accent fill for selected surfaces.
colors.onAccentSoft#0041B8#7DB0FFText and icons on the soft accent fill: accent badges, selected labels, success notices.
colors.onAccent#FFFFFF#001233Text and icons on the accent fill.
colors.secondary#E9E7E2#2F2C27Secondary control fill.
colors.secondaryEdge#C9C5BC#1C1A17Lower edge of a secondary control.
colors.onSecondary#1D1B17#F6F4EFText and icons on the secondary fill.
colors.danger#C43428#FF6F5EDestructive and error emphasis.
colors.dangerEdge#93261C#BC4538Lower edge of a destructive control.
colors.dangerSoft#FCE5E1#3B1D19Soft danger fill for error surfaces.
colors.onDanger#FFFFFF#2B0B07Text and icons on the danger fill.
colors.disabled#E6E4DF#2A2824Inactive control fill. Stays readable against its text.
colors.onDisabled#615D56#A09B92Text and icons on the disabled fill.
colors.pressTintrgba(20, 14, 4, 0.14)rgba(255, 255, 255, 0.12)Translucent overlay laid on a face while it is pressed.

Typography

Light and dark share these values.

TokenLightDarkRole
typography.fontFamilynonenoneFamily for ordinary text, loaded by your app. Unset, the platform font.
typography.fontFamilyStrongnonenoneFamily for emphasised text (titles, labels). Unset, fontFamily.
typography.sizes.caption1212The caption Text variant.
typography.sizes.label1313The label Text variant, and small button text.
typography.sizes.body1616The body Text variant, and default button text.
typography.sizes.title1717The title Text variant, and large button text.
typography.sizes.headline2222The headline Text variant.
typography.weights.regular400400Ordinary text.
typography.weights.medium500500Light emphasis.
typography.weights.semibold600600Labels and button text.
typography.weights.bold700700Strong emphasis, such as a selected label.

Sizes are in points and scale with the system text size.

Spacing

TokenLightDarkRole
spacing.xs44The smallest step.
spacing.sm88A tight step, inside a control.
spacing.md1616The common step: padding inside surfaces.
spacing.lg2020A wide step.
spacing.xl2424The widest step.

Shape

TokenLightDarkRole
shape.radiusSm1010Small keys, cells and inline notices.
shape.radiusMd1414Keys, inputs and rows.
shape.radiusLg1616Cards, notices, stats and tasks.
shape.radiusPill999999Fully rounded ends: pills and tracks.

Motion

TokenLightDarkRole
motion.pressIn7070Press-in duration, in milliseconds.
motion.pressOut160160Release duration, in milliseconds.

Advanced: depth

depth is the tactile material itself: how far a key travels, the shadows that seat it on the shell, the light along its rims and the recess of a well. The Material page explains each treatment. Change these tokens to tune the material for your palette, for example to tint the shadows with your ink, and check every control in both appearances afterwards.

TokenLightDarkWhat it draws
depth.travel44Press travel and visible lower edge of a regular key, in points.
depth.travelSm33Travel and edge of a small key.
depth.contacty 2, blur 3, rgba(29, 27, 23, 0.16)y 2, blur 4, rgba(0, 0, 0, 0.45)Contact shadow under a raised key.
depth.surfacey 2, blur 5, rgba(29, 27, 23, 0.12)y 1, blur 3, rgba(0, 0, 0, 0.3)Lift of a content surface resting on the page.
depth.surfaceNestedy 1, blur 2, rgba(29, 27, 23, 0.1)y 1, blur 2, rgba(0, 0, 0, 0.22)Shallower lift of a surface resting on another one.
depth.haloy 4, blur 8, rgba(29, 27, 23, 0.12)y 4, blur 10, rgba(0, 0, 0, 0.4)Soft occlusion on the shell around the base of a raised key.
depth.highlightrgba(255, 255, 255, 0.5)rgba(255, 255, 255, 0.24)Rim light along the top of a key or a surface.
depth.shadergba(29, 27, 23, 0.24)rgba(0, 0, 0, 0.38)Shade along the lower rim of a key, where it turns under.
depth.sheenlit rgba(255, 255, 255, 0.22) over shaded rgba(29, 27, 23, 0.08)lit rgba(255, 255, 255, 0.12) over shaded rgba(0, 0, 0, 0.16)Sheen down a key's face: the lit stop at the top, the shaded stop below.
depth.shellrim rgba(29, 27, 23, 0.24) over shade rgba(29, 27, 23, 0.1)rim rgba(0, 0, 0, 0.16) over shade rgba(0, 0, 0, 0)A surface's lower edge where it turns under, and the shade its lower half falls into.
depth.recessy 2, blur 3, rgba(29, 27, 23, 0.16)y 3, blur 5, rgba(0, 0, 0, 0.65)Shadow the upper lip of a well casts into it.
depth.liprgba(255, 255, 255, 0.75)rgba(255, 255, 255, 0.1)Light caught by the lower lip of a well.

Shadows are written as their vertical offset, blur and colour, in points. Every shadow is a React Native boxShadow, so the material looks the same on iOS and Android.

Contrast pairs

Text and icons sit on these pairs. Each must reach 4.5:1 to pass WCAG AA for body text. Check them whenever you change a colour. These are Electric's ratios, rounded down:

PairLightDarkWCAG AA
ink on background15.7:116.4:1Passes
ink on surface17.1:114.7:1Passes
inkSecondary on surface6.7:18.4:1Passes
inkMuted on background5.3:15.7:1Passes
inkMuted on surface5.8:15.1:1Passes
inkMuted on surfaceMuted4.9:15.5:1Passes
accent on background4.9:15.5:1Passes
accent on surface5.4:14.9:1Passes
onAccent on accent5.4:15.6:1Passes
onAccentSoft on accentSoft7.0:16.6:1Passes
onSecondary on secondary13.9:112.6:1Passes
onDanger on danger5.4:16.6:1Passes
danger on background4.9:16.6:1Passes
danger on surface5.4:15.9:1Passes
danger on dangerSoft4.5:15.5:1Passes
onDisabled on disabled5.1:15.3:1Passes

danger on dangerSoft is what a danger InlineNotice draws, so a theme that changes either colour should check that pair first: it has the least room.

On this page