Creating Themes with createThemeBuilder

The new theme-builder package makes creating suites of themes for Tamagui far easier, with some performance benefits to boot.

Before reading this guide, note that themes can be incredibly simple, and we highly recommend you avoid diving into complex themes unless you are on a large team that is willing to invest more effort. You can always just create a single light or dark theme at the root, and be on your way.

The Tamagui package @tamagui/theme-builder makes building complex theme suites easier. It gives you a chainable theme creation API. Because you may not want to bundle the extra overhead of themes, we've also added a themeBuilder option to the bundler plugins that lets you pre-generate your themes before runtime.

This guide goes into createThemeBuilder. If you want to skip to the API, check here, or you can just copy-paste a complete example here.

You don't need to create your own theme suite from the jump. Try using the built-in themes in @tamagui/config. This guide is for more advanced use cases.

Before we dive in, here's a minimal createThemeBuilder example to understand what we're building towards. It generates light, dark, light_subtle, and dark_subtle themes using all the concepts we'll cover in this guide: palettes, templates, masks, and themes:

Note that the @tamagui/theme-builder package requires TypeScript 5 or greater for const generic support.

import { createThemeBuilder } from '@tamagui/theme-builder'
const themesBuilder = createThemeBuilder()
.addPalettes({
dark: ['#000', '#111', '#222', '#999', '#ccc', '#eee', '#fff'],
light: ['#fff', '#eee', '#ccc', '#999', '#222', '#111', '#000'],
})
.addTemplates({
base: {
background: 0,
color: -0,
},
subtle: {
background: 1,
color: -1,
}
})
.addThemes({
light: {
template: 'base',
palette: 'light',
},
dark: {
template: 'base',
palette: 'dark',
},
})
.addChildThemes({
subtle: {
template: 'subtle',
},
})
export const themes = themesBuilder.build()

To optionally set up your compiler to automatically watch and build your themes at build-time (to save some bundle size), add the following to your compiler config (for example with Next.js):

withTamagui({
config: './tamagui.config.ts',
components: ['tamagui'],
// input is the file that imports @tamagui/theme-builder
// and has an `export const themes`
// output is then the file you import and use with your `createTamagui`
themeBuilder: {
input: './themes-input.tsx',
output: './themes.tsx',
},
})

You can also use the new @tamagui/cli package to enable npx tamagui generate-themes ./src/themes-in.ts ./src/themes-out.ts.

wwwwwwwwwwwwwwwwwww

The Concepts

The way the new ThemeBuilder works is through three main concepts: a palette, a template, and a mask. It's worth understanding each and how they relate to a design system before getting your hands dirty.

But first - what is a theme?

Themes

A theme is simple. It's a static typed object with properties that map from name => color. The simplest example of a theme is this:

{
background: '#000',
color: '#fff',
}

You can have as many values as you want in your themes, but what's important is that they share the same shape. Of course Tamagui themes get more interesting with their support of sub-themes, but the important things to remember are that themes share the same shape, and that sub-themes can be subsets of parent themes.

While we mostly deal with colors in this tutorial, themes can also take other strings or numbers as values. For now, this guide only focuses on color.

Palettes

The first layer of building a theme starts with a palette. A palette is typically a gradient within a single color, going from background to foreground, though it could also include contrasting colors if you so desired.

Here's an example of a blue palette:

Background

Foreground

You can toggle dark mode in the top left of the site to see that in fact we have two blue palettes: light_blue and dark_blue.

Here's that same palette in code (the dark_blue one):

const dark_blue = [
'hsl(212, 35.0%, 9.2%)', // background
'hsl(216, 50.0%, 11.8%)',
'hsl(214, 59.4%, 15.3%)',
'hsl(214, 65.8%, 17.9%)',
'hsl(213, 71.2%, 20.2%)',
'hsl(212, 77.4%, 23.1%)',
'hsl(211, 85.1%, 27.4%)',
'hsl(211, 89.7%, 34.1%)',
'hsl(206, 100%, 50.0%)',
'hsl(209, 100%, 60.6%)',
'hsl(210, 100%, 66.1%)',
'hsl(206, 98.0%, 95.8%)', // foreground
]

Palettes are great for a design system because they constrain your color choices to a consistent scale. Designs look better when they have constraints.

We can refer to a single color in the pallete by 0-index:

0

1

2

3

4

5

6

7

8

9

10

11

In this case 0 is the background, and 11 is the foreground.

Within Tamagui you can define your palettes to have as many or few colors as you like. You also technically don't have to go from background to foreground, but we recommend it if only for being consistent.

The offical @tamagui/themes theme suite that this websites uses adds one more layer to this equation - the 0-index color is actually a "background transparent", leaving the 1st index as the actual background, and correspondingly, the 12th index is the strongest foreground, while the 13th is "foreground transparent".

Templates

The next level up from a palette is a template. Templates are also pretty simple, they are used to generate a theme from a palette. They map a name to an index in your palette. The names can be whatever you want, and the index just refers to an offset of your palette.

In practice, it looks something like this:

{
background: 0,
color: 12
}

The tamagui components have standardized on the following minimum theme, so if you are generating themes for use with the tamagui components, you'll want to have your templates fill in these colors:

{
background: string
backgroundFocus: string
backgroundHover: string
backgroundPress: string
borderColor: string
borderColorFocus: string
borderColorHover: string
borderColorPress: string
color: string
colorFocus: string
colorHover: string
colorPress: string
colorTransparent: string
placeholderColor: string
shadowColor: string
shadowColorFocus: string
shadowColorHover: string
shadowColorPress: string
}

We could make a quick and hard-coded function that takes a template + palette and returns a theme, just to illustrate how they are used:

const createTheme = (palette: string[], colorTemplate: {
background: number
color: number
}) => ({
background: palette[colorTemplate.background],
color: palette[colorTemplate.color],
})
createTheme(dark_blue)
// => {
// background: 'hsl(212, 35.0%, 9.2%)',
// color: 'hsl(206, 98.0%, 95.8%)'
// }

So, why do this? Well, if we have more than one theme, we likely want to use the same template over and over. This generally makes sense when you match the lightness/saturation, but have a different hue. Even your base light and dark theme could share the same template.

The Tamagui site shares templates across all the color themes:

Blue

Red

In this case, we'd call createTheme with the same template, just changing out the red or blue palette:

const colorTemplate = {
background: 0,
color: 12,
}
const blue_theme = createTheme(bluePalette, colorTemplate)
const red_theme = createTheme(redPalette, colorTemplate)

This is nice. We can share a template but pass in different palettes, ensuring we can generate consistent themes but swap out for different palettes.

Still, the real utility of templates becomes most clear when we get into sub-themes.

Sub-themes

Let's take a quick detour. Tamagui themes can nest as many times as you want. This lets you do some amazing things. We can set up a "subtle" sub-theme that turns anything inside it to have a lower contrast feel:

const dark = {
background: 'black',
color: 'white',
}
const dark_subtle = {
background: '#222', // not as dark as black
color: '#ccc', // not as light as white
}
createTamagui({
themes: {
dark,
dark_subtle,
},
})

Note the _subtle. An underscore defines a sub-theme, so dark_subtle is a sub-theme of dark. In your code you can now do this:

import { View, Theme, styled } from '@tamagui/core'
const Square = styled(View, {
background: '$background',
width: 100,
height: 100,
})
export default () => (
<Theme name="dark">
{/* this will have a background of black */}
<Square />
<Theme name="subtle">
{/* this will have a background of #222 */}
<Square />
</Theme>
</Theme>
)

Sub-themes are amazing - they avoid a trap that you can fall into when designing screens where you decide you want a different look for an area, so you go off and change all the color values. But then later on you want to share that area somewhere else, or perhaps you just change your mind and want to revert the feel. In those two cases you'd either be stuck refactoring the whole area to accept two or more sets of ternaries on every color value, or you'd have to manually go through and change all the values by hand.

Instead with a sub-theme, you can throw <Theme name="subtle"> around the entire area without having to change any of the code inside of it at all.

Where it gets interesting is in a final feature of sub-themes: component themes, which are really just sub-themes in disguise.

Taking our example above, we can add a name to our styled call:

import { View, styled } from '@tamagui/core'
const Square = styled(View, {
name: 'Square',
backgroundColor: '$background',
width: 100,
height: 100,
})

And just like that, if we define a _Square sub-theme, any usage of <Square /> will pick it up:

// in your tamagui.config.ts:
const dark_Square = {
background: 'darkblue',
}
createThemes({
dark,
dark_Square,
})
// in your app:
export default () => (
<>
<Theme name="dark">
<Square />
{/*
Because Square has a name of Square it looks for a sub-theme with _Square.
It will find dark_Square and change the theme.
So in this case the Square backgroundColor will be 'darkblue'.
*/}
</Theme>
</>
)

This is how Tamagui "solves" themes. It gives you incredible power to re-skin the entire interface without having to touch any code. It's not mandatory - you can always just go in and change the color values inline as you please. But it does mean that we (and your team) can ship components and screens that can be completely re-skinned at any point in the tree.

Think of it as a super-power. If you don't use it, there's no downside. But if you do, you gain a pretty powerful new ability.

createThemeBuilder

Now that we have all the required context to understand palettes and templates, we can get familiar with the createThemeBuilder API.

Let's get back to our minimal example:

import { createThemeBuilder } from '@tamagui/theme-builder'
const themesBuilder = createThemeBuilder()
.addPalettes({
dark: ['#000', '#111', '#222', '#999', '#ccc', '#eee', '#fff'],
light: ['#fff', '#eee', '#ccc', '#999', '#222', '#111', '#000'],
})
.addTemplates({
base: {
background: 0,
color: -0,
},
subtle: {
background: 1,
color: -1,
}
})
.addThemes({
light: {
template: 'base',
palette: 'light',
},
dark: {
template: 'base',
palette: 'dark',
},
})
.addChildThemes({
subtle: {
template: 'subtle',
},
})
export const themes = themesBuilder.build()

This is the full API, minus some optional extra props that each function takes. Calling themesBuilder.build() will generate the following:

{
light: {
background: '#fff',
color: '#000',
},
dark: {
background: '#000',
color: '#fff',
},
light_subtle: {
background: '#eee',
color: '#111',
},
dark_subtle: {
background: '#111',
color: '#eee',
},
}

Complete Example

To get a better idea of a complete theme suite, check out the source for the @tamagui/themes package . This is the code that produces the themes for this site. It handles a variety of more complex use cases:

  • Adding multiple levels of themes, like light_orange, light_subtle, and light_orange_subtle.
  • Adding component themes like light_Button, light_orange_Button, and light_orange_subtle_Button.
  • Making the base (light and dark) themes have extra properties that are a superset of their children.
  • Creating and uses custom masks using combineMasks and createMask.
  • Diverging templates between light and dark by using the optional array syntax.
  • Passing options to some masks to "skip" changing certain values in the templates.
  • Using avoidNestingWithin to prevent combinations of sub-themes.