How to Build a Button
Learn how to create a powerful yet simple API for a button with Tamagui and compound components
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 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:
Now you can use your button like so:
Styling Text Color
In v3, color is not a direct prop on the Button frame. Instead, style your
text through Button.Text. For interactive color changes on hover or press, use
Tamagui’s group prop to coordinate styles between parent and child:
String
Typed
By setting group="btn" on the Button frame, any child can reference that group
with modifiers such as group-hover/btn: and group-press/btn:.
This is more flexible and explicit than passing color directly, as it lets you
independently control text, icon, and frame styles for every interaction state.
This may only be about a hundred lines of code, but there’s a lot to take in. And behind the scenes, there’s a lot going on 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 patterns exist, and also how they work.
We start with the assumption that we have a design system set up with tokens
that use keys sm, md, and lg and a few themes:
If you’d like to see a more complete work-up of a Tamagui config, check out
the Remix starter 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:
String
Typed
This gets us a simple rounded rectangle that uses the md tokens from your
design system and the background styles from your theme.
The displayName property identifies the styled component for configuration and
debugging. V3 does not select a light_Button theme from that name. The frame
reads the active theme’s semantic background values directly. Components that
need to sit above their surroundings wrap their frame in an explicit level2
theme boundary.
Finally, the hover and press object keys (or hover: and press: string
clauses) make the frame respond to interaction.
Next we’ll want a Text component to go inside:
This is pretty similar to the frame, just with its own name and with a color
set rather than a background. It reads the same active theme as the frame.
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:
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:
Still, this is somewhat repetitive and prone to typos. To avoid duplication while
also working if we add new sizes later, use a branded
styled.dynamic:
The generic parameter on styled.dynamic is the variant prop’s accepted type,
so callback values stay contextually typed under noImplicitAny.
So now we can pass in our new size property:
Let’s clean up these two components so they are more clearly meant to be used together (and are easier to import for users):
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:
Which is functionally the same as the above. So, now our users can do:
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:
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:
Not to mention by staying with the styled world, the Tamagui optimizing
compiler extracts CSS and optimizes our Button components. Note that components
using createStyledContext won’t be flattened, but CSS extraction and style
optimization still apply - see the createStyledContext docs
for details.
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:
What is createStyledHOC?
While it’s explained in the styled docs,
the short of it is: if you want a functional component that returns a styled
component to then be able to be styled again, you need createStyledHOC.
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:
This returns a slightly modified React.Context just like
createContext does, so you can use it with useContext later on. 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:
…and we’ve finally arrived at our destination! We can now do the following:
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:
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 a 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.
Now we can use it like this, assuming your Icon accepts width, height, and
color:
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 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 optimization. Note
that the compiler will still extract CSS and optimize these components, though
flattening won’t apply when using createStyledContext.
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 encourage you to give the compound component API a try and see how you like it.
With the new Tamagui APIs, sharing some props between parent and child components no longer means carefully adding a forwardRef, re-jigging the types, and threading context by hand, only to break memoization or scoped values anyway.