wwwwwwwwwwwwwwwwwww

How to Build a Button

Learn how to create a custom button, or any other type of component that uses a composable component API

Deceptive in its simplicity, creating a robust yet flexible button in any UI framework can be a surprisingly difficult task.

More than just some text in a box, you often have icons and loading indicators, a variety of colors and sizes, and a delicate balancing act gettings both spacing and styling correct not just in the base state, but also the focused, hovered, pressed and disabled states.

You also want consumers of your button to have an elegant API - simple up front, without sacrificing the ability to customize the styles on each child component.

Luckily Tamagui makes this much easier as of version 1.28 with the introduction of createStyledContext, which removes large amounts of brittle code in our codebase and should do the same for yours. It makes our "composable components" - components that are meant to render together with shared styling - feel more natural, both as an UI kit developer and user. So we've created this guide to show how you can use it, and why we've designed it the way we have.

Let's get into it.

The final code

Getting to the point, this is the final code we'll create for our Button, ready to copy and paste right into your app.

It gives you a powerful yet flexible Radix-like  composable component API:

import { getSize, getSpace } from '@tamagui/get-token'
import { Moon } from '@tamagui/lucide-icons'
import {
GetProps,
SizeTokens,
Stack,
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(Stack, {
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],
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)
const smaller = getSize(size, {
shift: -2,
})
const theme = useTheme()
return cloneElement(props.children, {
size: smaller.val * 0.5,
color: theme.color.get(),
})
}
export const Button = withStaticProperties(ButtonFrame, {
Props: ButtonContext.Provider,
Text: ButtonText,
Icon: ButtonIcon,
})

Now you may use your button like so:

export default (props: ButtonProps) => (
<Button {...props}>
<Button.Icon>
<Moon />
</Button.Icon>
<Button.Text>hi</Button.Text>
</Button>
)
// multiple icons:
export default (props: ButtonProps) => (
<Button {...props}>
<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 { Stack, styled } from '@tamagui/core'
const ButtonFrame = styled(Stack, {
// 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(Stack, {
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 { Stack, styled } from '@tamagui/core'
const ButtonFrame = styled(Stack, {
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,
})

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>
)

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
}: StackProps & {
textProps?: TextProps
}) => (
<ButtonFrame size={size} {...props}>
<ButtonText size={size} {...textProps}>
{children}
</ButtonText>
</ButtonFrame>
)

For example with the composable component API our users could really flexibly use our component - re-wrapping it in styled, or even adding different contents inside the frame quite flexibly:

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>
)

The composable component API really shines for example if we wanted to allow an icon of some sort. With the all-in-one functional component, you'd have to add an icon property, and then iconProps, and then probably some way to configure iconShouldAppearAfterText.

This expansion of abitrary props starts to feel off for good reason. It takes your users away from being able to understand the actual tree structure that's output. Things like wrapping your inner contents to make them flow a different direction, or space a bit differently suddenly become impossible, else you keep adding more and more arbitrary props.

Not to mention that by leaving the styled API, you'll now miss out on some of the nicest optimizations the Tamagui optimizing compiler can perform.

So we want our composable component API both for ergonomics and performance, but we need some way to thread our size prop down between components.

Passing down size

In React, this is typically what context is for.

Here's how we'd implement size through context using plain React, while keeping the composable 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,
})

Wait a minute! What the hell is ButtonText.styleable and ButtonFrame.styleable?

Well, while it's explained further in the styled docs, the short answer is if you want a functional component that returns a styled component to be able to be styled itself, then, well, you need this. That's because merging things like themes, animations, variants, media queries, and psuedo queries is quite complex, and if Tamagui doesn't know that there are multiple layers of styled components, it can merge things in ways you wouldn't expect. It would still work technically without styleable, but the output would sometimes defy your expectations.

But more imporantly that that, even beyond styleable, once again we've ended up with somewhat verbose and brittle code.

One example is that sharing more than just size across these components would require a pretty significant refactor. And once again, we've made things unable to be optimized due to leaving the styled world.

Introducing createStyledContext

Finally, we can use createStyledContext, first by changing from createContext to createStyledContext:

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

This is similar to createContext, in fact it returns a React.Context type, so you can use it with useContext later on, just as you'd expect.

Now we'll back the entire styled component definition for the next step, just to show more clearly how everything works together.

Only this time we pass our new styled context to the context property of styled:

import { Stack, styled, createStyledContext } from '@tamagui/core'
export const ButtonContext = createStyledContext({
size: '$md' as SizeTokens,
})
const ButtonFrame = styled(Stack, {
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="$xxl">
<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 brittle boilerplate, 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, letting us 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 as you can see is that the createStyledContext Provider doesn't take a value prop, but instead a flat object, as it always deals with objects.

This may seem a bit contrived of an example for a Button, but it can be an incredibly powerful pattern that we get now for free.

Adding an Icon

Are we done? Not quite. One final piece of a Button that is common is having an icon that sizes nicely with the Text. But your Icon likely comes from some third-party library, or is an SVG or perhaps an Icon font. We'll add a new component, Button.Icon.

So, instead of going through styled, it will be just a plain functional component that still nicely works with the Tamagui theme system and size.

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 composable 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 an ideal API, fully typed sizes and themes, and a great balance of customization to performance.

We'd recommend against the urge to go further and abstract this into a single Button as that would once again leave you with a limited API, but of course that decision is up to you and for many apps you may like the limitations and be able to accept a bit more abstraction cost.

And what about passing other props to createStyledContext? We've deliberately left the naming of this new context helper function somewhat generic. For now, we're only going to commit to supporting variants through it, but in the future we may look to expand it to support any style property. You are free to add other variants that work across both ButtonFrame and ButtonText though, and then pass them into createStyledContext.