Theming
TinyCld ships a light and a dark theme, and users can flip between them at runtime (and a separate “color theme” picker tints the accent palette on top of that). Every color in a package needs to come from a semantic token — never a raw hex. Two APIs cover every case: Tailwind class names with semantic tokens for JSX, and useThemeColor from @tinycld/core/lib/use-app-theme for places JSX isn’t an option.
Semantic tokens in JSX
Prefer Tailwind class names with semantic tokens:
<View className="bg-background border-border rounded-xl p-4">
<Text className="text-foreground font-semibold">Title</Text>
<Text className="text-muted-foreground text-sm">Subtitle</Text>
</View>
The token surface lives in @tinycld/core/lib/use-app-theme.ts as the AppThemeColor union. Both light and dark themes define every token, so JSX written against tokens renders correctly under either.
The names you’ll reach for most often:
background,foreground— the base surface and its text.surface,surface-foreground,surface-hover— a card-like raised surface.surface-secondary,surface-tertiary— additional depth steps for grouped panels.muted,muted-foreground— a subordinate surface and its de-emphasized text (sidebar hover, captions).accent,accent-foreground,accent-soft,accent-soft-foreground— the highlight color; the-softvariants are for low-emphasis backgrounds.default,default-foreground,default-hover— neutral filled affordances (default buttons).success,warning,danger(each with-foreground,-hover, and-soft/-soft-foreground/-soft-hovervariants) — status colors. Note:danger, notdestructive.field,field-foreground,field-placeholder,field-border,field-border-hover,field-border-focus,field-hover,field-focus— form input affordances.border,separator,focus,link— outlines, dividers, focus rings, link text.overlay,overlay-foreground,overlay-backdrop— modal/popover surfaces and their backdrop.on-surface,on-surface-foreground,on-surface-secondary,on-surface-tertiary(each with hover/focus variants) — for nesting content on top of an already-raised surface.
A handful of custom tokens are reserved for chrome that doesn’t map to the standard system (rail-background, rail-text, rail-active-text, sidebar-background, active-indicator, hover-background, info, info-foreground, primary, primary-foreground). Most package code doesn’t touch these.
If a design requires a color outside this union, the fix is to add a token to @tinycld/core/lib/use-app-theme.ts and tinycld/global.css, not to inline a hex.
Colors outside of className
Some APIs don’t accept a className: Lucide icons take a color prop, React Native Pressable’s style function is imperative, and a handful of third-party components want a literal hex. For those, use useThemeColor:
import { useThemeColor } from '@tinycld/core/lib/use-app-theme'
import { Search } from 'lucide-react-native'
export function SearchIcon() {
const fg = useThemeColor('foreground')
return <Search color={fg} size={20} />
}
useThemeColor reads the CSS variable for the named token and re-renders the component when the theme switches. It accepts any name from the AppThemeColor union.
Prefer className when it works
useThemeColor is a fallback, not the default. When both styles produce the same output, the className form is shorter, declarative, and survives token renames automatically. Use the hook only for the cases that genuinely need a string color value:
- Props that take a literal color:
<Search color={fg} />,<RefreshControl tintColor={…} />,shadowColor, gradient stops. - Style-callback APIs that don’t accept
className(e.g.Pressable’sstyle={({ pressed }) => …}). - Reanimated styles where the worklet needs a JS string.
The thing to avoid is plumbing a hook value straight into an inline style={{ color: fg }} when the className form would have worked:
// bad - verbose, drifts when tokens are renamed
const fg = useThemeColor('foreground')
const bg = useThemeColor('surface-secondary')
return (
<View style={{ backgroundColor: bg }}>
<Text style={{ color: fg }}>…</Text>
</View>
)
// good - same result, half the code
return (
<View className="bg-surface-secondary">
<Text className="text-foreground">…</Text>
</View>
)
If you find yourself calling useThemeColor only to feed the result into a <View style> or <Text style>, convert it to a className.
What not to do
Never hardcode a hex:
// bad - breaks dark mode, breaks accessibility tweaks,
// drifts out of sync with the design system
<View style={{ backgroundColor: '#ffffff' }} />
<Search color="#1f2937" size={20} />
If a design demands a color that isn’t in the token set, add a token — don’t inline a hex.
Don’t hardcode the mode
Both themes are first-class. Don’t write components that assume dark mode, and don’t build a “white-on-white” hack that only looks right in one theme. If you’re wiring a preview surface that must always be one mode regardless of the user’s pref, wrap it explicitly — but that’s rare enough that you should ask before doing it.
Reading the user’s choice
If you genuinely need to branch on the active theme (say, to pick an asset variant), read it through useThemePreference from @tinycld/core/lib/use-theme-preference:
import { useThemePreference } from '@tinycld/core/lib/use-theme-preference'
const { preference, resolved } = useThemePreference()
// preference: 'system' | 'light' | 'dark' ← the user's stored choice
// resolved: 'light' | 'dark' ← the mode actually rendering right now
Use resolved when you need to know which theme is on screen, and preference when you’re rendering the theme-picker UI itself.
Almost all theming concerns are solved by tokens though — reach for the mode only when you’ve confirmed tokens alone can’t express it.