wwwwwwwwwwwwwwwwwww

Creating Themes with createThemeBuilder

Learn how to create a suite of themes for a Tamagui app

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

With version 1.37 Tamagui has released two new things to help make authoring themes much easier:

  • createThemeBuilder - a chainable theme creation API exported from @tamagui/theme-builder.
  • themeBuilder - option for the Tamagui compiler to watches and generate themes.

The new ThemeBuilder makes it much easier to generate a suite of themes. Meanwhile the themeBuilder option automates pre-generating themes, which means you can avoid some runtime cost and bundle size.

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. You can use our built-in themes or roll your own by hand. Or you can just avoid themes altogether! This guide is for more advanced use cases where you want to generate a very custom look and feel for your app.

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 { createSoftenMask, 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,
},
})
.addMasks({
soften: createSoftenMask(),
})
.addThemes({
light: {
template: 'base',
palette: 'light',
},
dark: {
template: 'base',
palette: 'dark',
},
})
.addChildThemes({
subtle: {
mask: 'soften',
},
})
export const themes = themesBuilder.build()

And to set up your compiler to automatically watch and build your themes, 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 (and for being able to use our built-in masks, which we'll get into shortly).

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.

Masks

Ok, with the knowledge of sub-themes in hand, we're ready to see how masks fill in the final piece of the puzzle for letting us generate an entire suite of themes that give us base themes, sub-themes, and component themes.

Masks do one thing: they take an existing template, apply transformation, and return a new one. We could create a simple mask called "shift-right" that takes this template:

{
background: 0,
color: 6,
}

And does this:

shiftRightMask({
background: 0,
color: 5,
})
/**
* => {
* background: 1,
* color: 6
* }
*/

Is it useful? Not really, but it explains what they do.

To understand their utility we can revisit component themes. Let's build it up again. We start with our base theme, dark_blue. It uses a template with background 0 and color 11, which gives us a nice dark background and bright color.

Now we want to theme a dark_blue_Button. We want our Button to stand out from the dark_blue theme, so we want to shift our background from 0 to be something above it: maybe 1, or 2. We can create a new mask function called strengthenBackground that is defined somewhat like this.

Keep in mind this is just a hard-coded example of a mask for simplicity:

const strengthenBackground = (template) => ({
background: template.background + 1,
})

So now we can do:

const darkPalette = ['black', '#222', '#444', '#666', '#888', '#aaa', 'white']
const baseTemplate = {
background: 0,
color: 6,
}
const dark = createTheme(baseTemplate, darkPalette)
// dark = { background: 'black', color: 'white' }
const buttonTemplate = strengthenBackground(baseTemplate)
const dark_Button = createTheme(buttonTemplate, darkPalette)
// dark_Button = { background: '#222', color: 'white' }

The ThemeBuilder basically gives you generic versions of masks much like this.

There's one last wrinkle though: what if we want to make the backgrounds lighter, but the foregrounds softer? How can masks do this in a generic way, without having to assume things about how you name your keys?

Let's revisit our palette.

0

1

2

3

4

5

6

7

8

9

10

11

We want a mask called subtlerMask that takes a template and makes backgrounds increment while foregrounds decrement - if we have a template with background 0 and color 11, then our subtlerMask should return background 1 and color 10.

The issue here is that the only way we could "know" to move the background +1 and the color -1 is if we had some logic that basically checked "if index < 50%, +1, else -1". Except what if we have a foreground that's at 50%? We'd end up shifting it the wrong way!

So this is where we introduce one final concept with templates, negative indices:

-11

-10

-9

-8

-7

-6

-5

-4

-3

-2

-1

-0

0

1

2

3

4

5

6

7

8

9

10

11

By adding the idea of a negative index that represents a foreground (while positive indices represent backgrounds), we can change our template as follows:

{
background: 0,
color: -0,
}

It's pretty easy from here to modify our example createTheme function to handle negative indices:

const createTheme = (palette: string[], template: Record<string, number>) => {
const res = {}
for (const key in template) {
const index = template[key]
res[key] = index < 0 ? palette[palette.length - 1 + index] : palette[index]
}
return res
}

Now we have everything we need. We'll just make the mask subtlerMask "know" when a value is meant to be a foreground or a background by checking if negative, and making it shift appropriately:

subtlerMask({
background: 0,
color: -0,
})
/**
* => {
* background: 1,
* color: -1
* }
*/

And that's all masks really are - functions that take a template and return a template.

They help us especially with sub-themes and component themes, where often you want to take an existing theme, like dark, and then return a dark_Button theme where the background is a bit stronger, and maybe the color a bit softer. So long as we define our templates using positive values for backgrounds and negative values for foregrounds, masks have all the info they need to make transformations.

In @tamagui/theme-builder we've included a variety of masks we found useful:

  • createInverseMask - Flips background and foreground.
  • createSoftenMask - Makes background and foreground move closer to center.
  • createStrengthenMask - Makes background and foreground move further from center.

We also export two helper functions for creating your own masks:

  • createMask - Passes some standardized options and a template to the mask function you give it.
  • combineMasks - Combine two masks.
wwwwwwwwwwwwwwwwwww
wwwwwwwwwwwwwwwwwww

createThemeBuilder

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

Let's get back to our minimal example:

import { createSoftenMask, 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,
},
})
.addMasks({
soften: createSoftenMask(),
})
.addThemes({
light: {
template: 'base',
palette: 'light',
},
dark: {
template: 'base',
palette: 'dark',
},
})
.addChildThemes({
subtle: {
mask: 'soften',
},
})
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.

Previous

Benchmarks

Next

How to Build a Button