How to Build a Button

Deceptively innocuous, designing a robust yet flexible API even for a Button can be filled with surprising challenges.

More than just text in a box, buttons need icons, themes, variants, sizes, often loading indicators and several interactive states, and always a delicate balance between sub-components' positions, spacing and style.

All of this ideally delivered in a simple API that still allows complete customization.

Tamagui allows for building a button with "compound components," a concept popularized by Radix . With the introduction of createStyledContext in version 1.28 this pattern has become especially easy, so we wrote this guide to show off building more advanced compound component APIs. While this guide covers a button, this is applicable to many types of UI components.

So what are compound components? Simply, it's when you have, say, a <Button /> that wants children <Button.Text /> and <Button.Icon />.

And why do they exist? Well, we'll explain that and more in this guide.

Keep in mind that this is an advanced use case. Usually you can just grab Tamagui components off the shelf and use them with inline styles. But sometimes you need components that want for multiple related children.

This guide is more of a primer on designing compound components, teaching advanced concepts that aren't necessary if you just want to build your app. If you just want a simple, ready to use Button, you can use the Tamagui Button itself directly.

The Final Result

To the point, here is the final code we'll end up with for our Button, ready to copy and paste right into your app:

import { getSize, getSpace } from '@tamagui/get-token'
import {
GetProps,
SizeTokens,
View,
Text,
createStyledContext,
styled,
useTheme,
withStaticProperties,
} from '@tamagui/web'
import { cloneElement, useContext } from 'react'
export const ButtonContext = createStyledContext({
size: '$md' as SizeTokens,
})
export const ButtonFrame = styled(View, {
name: 'Button',
context: ButtonContext,
backgroundColor: '$background',
alignItems: 'center',
flexDirection: 'row',
hoverStyle: {
backgroundColor: '$backgroundHover',
},
pressStyle: {
backgroundColor: '$backgroundPress',
},
variants: {
size: {
'...size': (name, { tokens }) => {
return {
height: tokens.size[name],
borderRadius: tokens.radius[name],
// note the getSpace and getSize helpers will let you shift down/up token sizes
// whereas with gap we just multiply by 0.2
// this is a stylistic choice, and depends on your design system values
gap: tokens.space[name].val * 0.2,
paddingHorizontal: getSpace(name, {
shift: -1,
}),
}
},
},
} as const,
defaultVariants: {
size: '$md',
},
})
type ButtonProps = GetProps<typeof ButtonFrame>
export const ButtonText = styled(Text, {
name: 'ButtonText',
context: ButtonContext,
color: '$color',
userSelect: 'none',
variants: {
size: {
'...fontSize': (name, { font }) => ({
fontSize: font?.size[name],
}),
},
} as const,
})
const ButtonIcon = (props: { children: any }) => {
const { size } = useContext(ButtonContext.context)
const smaller = getSize(size, {
shift: -2,
})
const theme = useTheme()
return isValidElement(props.children) ? cloneElement(props.children, {
size: smaller.val * 0.5,
color: theme.color.get(),
}) : null
}
export const Button = withStaticProperties(ButtonFrame, {
Props: ButtonContext.Provider,
Text: ButtonText,
Icon: ButtonIcon,
})

Now you can use your button like so:

export default () => (
<Button>
<Button.Icon>
<Moon />
</Button.Icon>
<Button.Text>hi</Button.Text>
</Button>
)
// multiple icons:
export default () => (
<Button>
<Button.Icon>
<Moon />
</Button.Icon>
<Button.Text>hi</Button.Text>
<Button.Icon>
<Moon />
</Button.Icon>
</Button>
)

This may only be a ~hundred lines of code, but there's a lot to take in. And behind the scenes, there's a lot going on behind to make this possible.

From the Start

This guide will build up to this complete example from the ground up, which should help explain both why many pattern exist, and also how they work.

We start with the assumption that we have a design system set up with a tokens that use keys sm, md, and lg and a few themes:

import { createTamagui, createTokens } from '@tamagui/core'
export default createTamagui({
tokens: createTokens({
size: {
sm: 38,
md: 46,
lg: 60,
},
space: {
sm: 15,
md: 20,
lg: 25,
},
radius: {
sm: 4,
md: 8,
lg: 12,
},
// ... the rest of your tokens
}),
themes: {
light: {
background: '#fff',
color: '#000',
},
// define a Button sub-theme, see the Themes docs for more
light_Button: {
background: '#ccc',
backgroundPress: '#bbb', // darker background on press
backgroundHover: '#ddd', // lighter background on hover
color: '#222'
},
},
// ... the rest of your tamagui.config.ts
})

If you'd like to see a more complete work-up of a Tamagui config, check out the Simple Web starter repo source code  or go ahead and create that starter using npm create tamagui@latest.

With that in mind, we can create the outer frame of the button fairly simply, like so:

import { View, styled } from '@tamagui/core'
const ButtonFrame = styled(View, {
// the name indicates to use the sub-theme `Button`
// since we defined light_Button, if our theme is light, this component
// will always use the values from our `light_Button` theme
name: 'Button',
alignItems: 'center',
flexDirection: 'row',
// our $ prefixed values look for theme first, then fallback to tokens
backgroundColor: '$background', // #ccc
hoverStyle: {
backgroundColor: '$backgroundHover', // #ddd
},
pressStyle: {
backgroundColor: '$backgroundPress', // #bbb
},
// these all use tokens
// note that size tokens are used only for these properties:
// width, height, minWidth, minHeight, maxWidth and maxHeight
height: '$md', // 46
// meanwhile our radius token is used here
borderRadius: '$md', // 8
// and space tokens are used for all others
paddingHorizontal: '$sm', // 25
})

This gets us a simple rounded rectangle that uses the md tokens from your design system and the background styles from your theme.

We set the name property to "Button", which tells Tamagui to check for a sub-theme Button that extends the current one. So since we have a theme light and we have a sub-theme light_Button, Tamagui find the theme light_Button and apply it to this component, getting the background value from that theme (assuming we have our base theme set to light).

Finally we set the hoverStyle and pressStyle so our button frame looks nice when hovered or pressed.

Next we'll want a Text component to go inside:

import { Text, styled } from '@tamagui/core'
export const ButtonText = styled(Text, {
name: 'ButtonText',
color: '$color',
fontFamily: '$body',
fontSize: '$md',
lineHeight: '$md',
userSelect: 'none',
})

This is pretty similar to the frame, just with its own name and with a color set rather than a background. Since we didn't define a light_ButtonText theme, this will fall back to the color defined on the light theme. Finally, button text usually isn't selectable so we set userSelect to none.

We can use our simple button. It's nicely themed, but only renders at one size:

export default () => (
<Button>
<ButtonText>
Hello world
</ButtonText>
</Button>
)

So we'll make it sizeable. First, let's look at how we'd solve this without the new createStyledContext helper, so we can understand why it exists.

We can add some variants:

const ButtonFrame = styled(View, {
name: 'Button',
backgroundColor: '$background',
alignItems: 'center',
flexDirection: 'row',
variants: {
size: {
sm: {
height: '$sm',
borderRadius: '$sm',
gap: 4,
},
md: {
height: '$md',
borderRadius: '$md',
gap: 6,
},
lg: {
height: '$lg',
borderRadius: '$lg',
gap: 8,
},
},
} as const,
})

Still, this is somewhat repetitive and prone to mistakes via typo. To allow us to avoid duplication while also always working even if we add a new sizes to our design system in the future, we can use the handy Tamagui Spread Variants:

import { View, styled } from '@tamagui/core'
const ButtonFrame = styled(View, {
name: 'Button',
backgroundColor: '$background',
alignItems: 'center',
flexDirection: 'row',
variants: {
size: {
'...size': (name, { tokens }) => {
return {
height: tokens.size[name],
borderRadius: tokens.radius[name],
gap: tokens.space[name].val * 0.2,
}
},
},
} as const,
})
export const ButtonText = styled(Text, {
name: 'ButtonText',
color: '$color',
userSelect: 'none',
variants: {
size: {
'...fontSize': (name, { font }) => ({
fontSize: font?.size[name],
}),
},
} as const,
})

The ...size spread variant name defaults to implicit any. Update tsconfig.json's "noImplicitAny": false to avoid TypeScript errors.

So now we can pass in our new size property:

<ButtonFrame size="$md">
<ButtonText size="$md" />
</ButtonFrame>

Lets just clean up these two components so they are more clearly meant to be used together (and are easier to import for users):

export const Button = ButtonFrame as typeof ButtonFrame & {
Text: typeof ButtonText
}
Button.Text = Text

What is this whole typeof ButtonFrame thing? It's just TypeScript being awkward. Since Tamagui uses this pattern internally for many components, we've made a small helper:

import { withStaticProperties } from '@tamagui/core'
export const Button = withStaticProperties(ButtonFrame, {
Text: ButtonText,
})

Which is functionally the same as the above. So, now our users can do:

import { Button } from './OurButton'
export default () => (
<Button size="$md">
<Button.Text size="$md">Hello world</Button.Text>
</Button>
)

Improving the API

Great. We've gotten our custom Button exported. But there's something quite unfortunate about our API as it stands, and that is that we have to always pass size to both components. This is brittle and ugly.

One typical way to solve this would be to abstract both of these components into our own React component:

const Button = ({
size,
children,
textProps,
...props
}: ViewProps & {
textProps?: TextProps
}) => (
<ButtonFrame size={size} {...props}>
<ButtonText size={size} {...textProps}>
{children}
</ButtonText>
</ButtonFrame>
)

But suddenly we have issues. What if a user wants to show an icon? We need to add icon and iconProps. And what if they want to change the order? You may reach for something like direction="reverse". But then what if they want a loading indicator after, but an icon before. Or maybe they want to show big text above, and smaller text below.

The problem with abstracting your Button into a single component is that the internals are surprisingly flexible, and ideally they want to each be styled independently.

Abstracting the tree structure that's output is our problem. The compound component API gives us full control - we can customize every piece, even using the existing styled function to re-style each piece, before re-exporting it again:

import { withStaticProperties } from '@tamagui/core'
import { Button } from './OurButton'
const CustomButtonFrame = styled(Button, {
// override some styles
})
const CustomButtonText = styled(Button.Text, {
// override some styles
})
export const CustomButton = withStaticProperties(CustomButtonFrame, {
Text: CustomButtonText,
})
export default () => (
<CustomButton>
<CustomButton.Text>Hello world</CustomButton.Text>
<CustomButton.Text size="$sm">(Smaller text)</CustomButton.Text>
</CustomButton>
)

Not to mention by staying with the styled world, the Tamagui optimizing compiler confidently flattens our Button components down to ridiculously fast DOM output on the web, and pulls out all styles from the render loop on native.

So we want our compound component API both for ergonomics and for performance, but we need some way to thread our size property down from the parent frame to the inner sub-components.

Solving size

In React this is typically what context is used for.

Here's how we'd solve for size using context in plain-old React, while still keeping the compound API:

import { createContext } from 'react'
import { SizeTokens, GetProps, withStaticProperties } from '@tamagui/core'
import { Button } from './OurButton'
const SizeContext = createContext<SizeTokens>('$md')
const ButtonFrame = Button.styleable(
({ size = '$md', ...props }: GetProps<typeof OGB.ButtonFrame>) => {
return (
<SizeContext.Provider value={size}>
<Button size={size} {...props} />
</SizeContext.Provider>
)
},
)
const ButtonText = Button.Text.styleable(
(props: GetProps<typeof OGB.ButtonText>) => {
const size = useContext(SizeContext)
return <Button.Text size={size} {...props} />
},
)
export const NewButton = withStaticProperties(ButtonFrame, {
Text: ButtonText,
})

What the hell is ButtonText.styleable and ButtonFrame.styleable?

Well, while it's explained in the styled docs but the short of it is if you want a functional component that returns a styled component to then be able to be styled again, well, you need styleable.

That's because merging things like themes, animations, variants, media queries, and pseudo queries is quite complex, and if Tamagui doesn't know that there is a functional component in between some styled components it can lead to unexpected merging of styles.

More importantly, the above code is once again verbose, duplicative, and brittle. For example sharing more than just size across these components would require a pretty significant refactor of the context with a lot of logic memoizing the context value properly. And, once again, by moving into functional components, your base level views now won't be as optimized by the compiler.

The solution, createStyledContext

Finally we get to use createStyledContext. We just change from createContext to:

import { createStyledContext } from '@tamagui/core'
export const ButtonContext = createStyledContext({
size: '$md' as SizeTokens,
})

This in fact it returns a slightly modified React.Context just like createContext does, so you can use it with useContext later on - though we still export a .context object with the original type just in case there's any issue.

And now, bringing back the full example, we add an extra property to each styled component, context:

import { View, styled, createStyledContext } from '@tamagui/core'
export const ButtonContext = createStyledContext({
size: '$md' as SizeTokens,
})
const ButtonFrame = styled(View, {
name: 'Button',
context: ButtonContext,
backgroundColor: '$background',
alignItems: 'center',
flexDirection: 'row',
variants: {
size: {
'...size': (name, { tokens }) => {
return {
height: tokens.size[name],
borderRadius: tokens.radius[name],
gap: tokens.space[name].val * 0.2,
}
},
},
} as const,
})
const ButtonText = styled(Text, {
name: 'ButtonText',
context: ButtonContext,
color: '$color',
userSelect: 'none',
variants: {
size: {
'...fontSize': (name, { font }) => ({
fontSize: font?.size[name],
}),
},
} as const,
})
export const Button = withStaticProperties(ButtonFrame, {
Text: ButtonText,
Props: ButtonContext.Provider,
})

...and we've finally arrived at our destination! We can now do the following:

import { Button } from './OurButton'
export default () => (
<Button size="$md">
<Button.Text>
Hello world
</Button.Text>
</Button>
)

This time since Button knows the size property is defined in the context it will automatically pass size down from Button to Text, just like our hand-rolled version did.

But - we don't have to write terribly verbose and brittle code, we get nice types and memoization automatically, and the optimizing compiler is happy.

Notice we also exported a new property on Button, Button.Props.

Since createStyledContext returns a regular React.Context value, this works the same as any other React provider. We can now control the variant from anywhere above in the React tree:

import { Button } from './OurButton'
export default () => (
<Button.Props size="$md">
<Button>
<Button.Text>
Hello world
</Button.Text>
</Button>
</Button.Props>
)

The only difference from createContext is that the createStyledContext Provider just takes props directly rather than inside a value object (and of course the memoization comes for free).

Adding an Icon

Are we done?

Not quite. One final common piece of a Button is having an icon that sizes and colors properly as well. But an icon usually comes from some third-party library, an SVG or perhaps an icon font.

Let's add a new component, Button.Icon and make this work.

Instead of going through styled, this component will be just a plain functional component. It will still work nicely with the Tamagui themes and sizes, showing how you can use them together with external React components.

import { getTokens, useTheme } from '@tamagui/core'
import * as React from 'react'
const ButtonIcon = (props: { children: React.ReactNode }) => {
const { size } = React.useContext(ButtonContext)
const tokens = getTokens()
const smallerSize = tokens.size[size].val * 0.5
const theme = useTheme()
return React.cloneElement(props.children, {
width: smallerSize,
height: smallerSize,
color: theme.color.get(),
})
}
// add it to your Button:
export const Button = withStaticProperties(ButtonFrame, {
Text: ButtonText,
Icon: ButtonIcon,
Props: ButtonContext.Provider,
})

Now we can use it like this, assuming your Icon accepts width, height, and color:

import { MyIcon } from 'some-icon-library'
export default () => (
<Button size="$lg">
<Button.Icon>
<MyIcon />
</Button.Icon>
<Button.Text>
Hello world
</Button.Text>
</Button>
)

Of course you can make your Button.Icon work a bit differently if you'd like, say changing out children + cloneElement for something like <Button.Icon icon={MyIcon} />. It's up to you.

Conclusion

We hope this has been helpful in explaining a variety of Tamagui features and some of the benefits of and ideas behind compound components.

There's certainly further you can go in building out your Button, but we think that in under 150 lines of code you're getting a nearly ideal API, fully typed sizing and themes, and a great balance of customization to performance.

If you are working on a smaller app, say one that shows only a few buttons at a time or with a small set of button variations, then abstracting your Button into a single functional Button component is totally fine.

But we'd that encourage you to give the compound component API a try and see how you like it.

With the new Tamagui APIs, we look forward to never having to carefully add a forwardRef, re-jig the types, and then manually thread context just to share some props between parent and child components, especially when even after all of that we'd still somehow accidentally break memoization or scoped values!