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 valuesgap: 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 morelight_Button: {background: '#ccc',backgroundPress: '#bbb', // darker background on pressbackgroundHover: '#ddd', // lighter background on hovercolor: '#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` themename: 'Button',alignItems: 'center',flexDirection: 'row',// our $ prefixed values look for theme first, then fallback to tokensbackgroundColor: '$background', // #ccchoverStyle: {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 maxHeightheight: '$md', // 46// meanwhile our radius token is used hereborderRadius: '$md', // 8// and space tokens are used for all otherspaddingHorizontal: '$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.5const 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!