How to build a universal design system
Using React Native for Web, Tailwind CSS and Dripsy.
"Universal" and "design system" can mean different things so let me clarify:
Universal: that works on all platforms (Web, iOS, Android...).
Design system: a library of UI elements and other components that share a common design language. A good example is Vercel's Geist.
The problem is: how do you build a design system that works on React DOM and React Native, with a familiar styling solution that can be shared across multiple apps and used by multiple teams?
Before we jump in, let me give you a little bit of context. I just joined Showtime to build the mobile app and I'm excited to help create the design system for the mobile app. But I'm also a web dev and I want to be able to use this new design system on the web app as well.
We are currently using Tailwind to style our web app. It's a great solution so I wanted to find a way to use it on the mobile app as well. This will help keep our native and web app in sync. And I think it goes even deeper than just sharing some code -- it will also bring the web and native teams closer together and it will help us move much faster. We will learn from each other and everything will be better.
Another good reason to use Tailwind is given by Dan Abramov:
Yes, you can easily copy paste styles from Tailwind! This will be helpful to share styles. Now, imagine if you could do this and still benefit from React Native APIs...
This is what we do here. Ditch your div
s and span
s -- let's use React Native
components and let's get the best of both worlds.
You benefit from the power and interopability of React Native for Web, the simplicity and scalability of Tailwind CSS and low-level building blocks from Dripsy.
React Native for Web
React Native Components and APIs for the Web.
This is the basis of our design system. Here is a good introduction to RNW by his creator, Nicolas Gallagher: Twitter Lite, React Native, and Progressive Web Apps.
Tailwind React Native Classnames
A simple, expressive API for TailwindCSS + React Native, written in TypeScript.
This is a great way to use Tailwind CSS in React Native and
this is the nicest way to implement dark mode and responsive design in your app.
You even have cool utilities like platform prefixes:
ios:text-purple-900 android:text-purple-800 web:text-purple-700
.
Dripsy
Responsive, unstyled UI primitives for React Native + Web.
Dripsy is used in our case as a set of low-level building blocks for React Native. You can learn more about Dripsy on this talk at Next.js conf by Fernando Rojo: Zero to $10 Million with React Native + Next.js.
If you are coming from a web development background, you might be wondering what are the differences between React DOM and React Native. I recommend to watch Native for React Developers by Lydia Hallie and Evan Bacon.
Here is a quick overview of the different APIs:
-> or or
-> or
->
->
documentation.
You don't need to use the flex
utility from your current Tailwind CSS styles.
Here are some important notes about the Flexbox implementation in React Native:
Flexbox works the same way in React Native as it does in CSS on the web,
with a few exceptions. The defaults are different, with flexDirection
defaulting to column
instead of row
, alignContent
defaulting to flex-start
instead of stretch
, flexShrink
defaulting to 0
instead of 1
, the
flex
parameter only supporting a single number.
OK so let's check some code from our design system. Here is the Dripsy theme:
app/design-system/theme.tsimport { makeTheme } from 'dripsy'import { Platform } from 'react-native'
import { fontFamily, textSizes} from 'app/design-system/typography'
const webFont = (font: string) => { return Platform.select({ web: `"${fontFamily( font )}", Arial, Helvetica Neue, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"`, default: font })}
const theme = makeTheme({ space: [], fontSizes: [], fonts: { root: 'Inter', inter: 'Inter' }, customFonts: { Inter: { default: webFont('Inter-Regular'), normal: webFont('Inter-Regular'), regular: webFont('Inter-Regular'), 400: webFont('Inter-Regular'), semibold: webFont('Inter-Semibold'), 500: webFont('Inter-Semibold'), bold: webFont('Inter-Bold'), 600: webFont('Inter-Bold'), 700: webFont('Inter-Bold'), }, }, text: { 'text-xs': { fontWeight: 'default', ...textSizes['text-xs'], }, 'text-sm': { fontWeight: 'default', ...textSizes['text-sm'], }, // `body` is the default text variant in Dripsy body: { fontWeight: 'default', ...textSizes['text-base'], }, 'text-base': { fontWeight: 'default', ...textSizes['text-base'], }, 'text-lg': { fontWeight: 'default', ...textSizes['text-lg'], }, 'text-xl': { fontWeight: 'default', ...textSizes['text-xl'], }, 'text-2xl': { fontWeight: 'default', ...textSizes['text-2xl'], }, 'text-3xl': { fontWeight: 'default', ...textSizes['text-3xl'], }, 'text-4xl': { fontWeight: 'default', ...textSizes['text-4xl'], }, },})
type MyTheme = typeof theme
declare module 'dripsy' { interface DripsyCustomTheme extends MyTheme {}}
export { theme }
We use this Dripsy theme for global custom fonts support and text variants.
In React Native, you otherwise need to set the font family for every
text element. But with Dripsy, you can use the fonts
property to set the
font family for every text element.
Note that we are importing textSizes
from
app/design-system/typography
. This is where we use capsize
:
app/design-system/typography/index.ts// Based on https://github.com/rainbow-me/rainbow/blob/3e2059381cc30e988196cbadaee6fd0e41673b3d/src/design-system/typography/typography.ts
import { precomputeValues } from '@capsizecss/core'import { Platform, PixelRatio } from 'react-native'
export const fontFamily = (font: string) => { if (Platform.OS === 'web') { return font.replace(/\-/g, ' ') }
return font}
const capsize = (options: Parameters<typeof precomputeValues>[0]) => { const values = precomputeValues(options) const fontSize = parseFloat(values.fontSize) const baselineTrimEm = parseFloat(values.baselineTrim) const capHeightTrimEm = parseFloat(values.capHeightTrim) const fontScale = PixelRatio.getFontScale()
return { fontSize, lineHeight: values.lineHeight !== 'normal' ? parseFloat(values.lineHeight) : undefined, marginBottom: PixelRatio.roundToNearestPixel( baselineTrimEm * fontSize * fontScale ), marginTop: PixelRatio.roundToNearestPixel( capHeightTrimEm * fontSize * fontScale ) } as const}
// Sourced from https://seek-oss.github.io/capsizeconst fontMetrics = { capHeight: 2048, ascent: 2728, descent: -680, lineGap: 0, unitsPerEm: 2816}
const createTextSize = ({ fontSize, lineHeight: leading, letterSpacing, marginCorrection,}: { fontSize: number lineHeight: number letterSpacing: number marginCorrection: { ios: number android: number }}) => { const styles = { letterSpacing, ...capsize({ fontMetrics, fontSize, leading, }), } as const
const marginCorrectionForPlatform = marginCorrection[Platform.OS] ?? 0
return { ...styles, marginTop: PixelRatio.roundToNearestPixel(styles.marginTop + marginCorrectionForPlatform), marginBottom: PixelRatio.roundToNearestPixel(styles.marginBottom - marginCorrectionForPlatform), }}
export const textSizes = { 'text-xs': createTextSize({ fontSize: 12, letterSpacing: 0.6, lineHeight: 15, marginCorrection: { android: -0.1, ios: -0.3, }, }), 'text-sm': createTextSize({ fontSize: 14, letterSpacing: 0.6, lineHeight: 17, marginCorrection: { android: -0.1, ios: -0.3, }, }), 'text-base': createTextSize({ fontSize: 16, letterSpacing: 0.5, lineHeight: 19, marginCorrection: { android: -0.1, ios: -0.5, }, }), 'text-lg': createTextSize({ fontSize: 18, letterSpacing: 0.5, lineHeight: 21, marginCorrection: { android: 0.2, ios: 0, }, }), 'text-xl': createTextSize({ fontSize: 20, letterSpacing: 0.6, lineHeight: 23, marginCorrection: { android: 0, ios: -0.5, }, }), 'text-2xl': createTextSize({ fontSize: 24, letterSpacing: 0.6, lineHeight: 27, marginCorrection: { android: -0.3, ios: -0.3, }, }), 'text-3xl': createTextSize({ fontSize: 30, letterSpacing: 0.6, lineHeight: 33, marginCorrection: { android: -0.3, ios: -0.3, }, }), 'text-4xl': createTextSize({ fontSize: 36, letterSpacing: 0.6, lineHeight: 41, marginCorrection: { android: -0.3, ios: -0.3, }, }),} as const
Capsize makes the sizing and layout of
text as predictable as every other element on the screen. Using font metadata,
text can now be sized according to the height of its capital letters while
trimming the space above capital letters and below the baseline.
What it means is that we now have a predictable size and layout
for every text element on every browsers and every platforms.
Fonts can be displayed differently because of the white space around text,
it can be inconsistent, maybe it doesn't align correctly...
well, it can be frustrating. Capsize solves this.
Next up: tailwind/index.ts
. The tailwind config is the exact same as
what we already have on web. It's very helpful!
app/design-system/tailwind/index.tsimport { create } from 'twrnc'
const tw = create(require('./tailwind.config.js'))
export { tw }
app/design-system/index.tsxexport { View } from 'app/design-system/view'
export { ScrollView } from 'app/design-system/scroll-view'
export { Text } from 'app/design-system/text'
export { TextInput } from 'app/design-system/text-input'
export { Gradient } from 'app/design-system/gradient'
export { Pressable } from 'app/design-system/pressable-scale'
export { Button, ButtonLabel } from 'app/design-system/button'
export { Image } from 'app/design-system/image'
export { Modal } from 'app/design-system/modal'
export { ActivityIndicator } from 'app/design-system/activity-indicator'
And as an example, here is text/index.tsx
.
app/design-system/text/index.tsximport { ComponentProps } from 'react'import { Text as DripsyText, Theme } from 'dripsy'
import { tw as tailwind } from 'app/design-system/tailwind'
type Variant = keyof Theme['text']
type TextProps = { tw?: string; variant?: Variant } & Omit<ComponentProps<typeof DripsyText>, 'variant'>
// Note: You can wrap in a with a background color // to verify if the text is rendered correctly and if Capsize is working well.
function Text({ tw, sx, variant, ...props }: TextProps) { return ( <DripsyText sx={{ ...sx, ...tailwind.style(tw) }} variant={variant} {...props} /> )}
export { Text }
twrnc
lets you easily handle dark mode and responsive like you would do with
Tailwind CSS so it's a really nice implementation.
You can even compose and do crazy stuff like md:dark:text-stpurple700
:
app/hello-world.tsximport { View, Text } from 'app/design-system'
export function HelloWorld() { return ( <View tw="flex-1 justify-center items-center bg-white dark:bg-black"> <Text tw="text-black dark:text-white md:dark:text-stpurple700"> Hello, World! Text> View> )}
And you can also use a text variant to control the text size following Tailwind
text-{size}
utilities:
app/hello-world.tsximport { View, Text } from 'app/design-system'
export function HelloWorld() { return ( <View tw="flex-1 justify-center items-center bg-white dark:bg-black"> <Text variant="text-lg" tw="text-black dark:text-white md:dark:text-stpurple700"> Hello, World! Text> View> )}
Pro tip: you can add tw
to Tailwind CSS: Class Attributes
VS Code extension setting to get IntelliSense working!
What's next? Now we need to create the more complex components of
our design system. The missing piece to do this easily is a set of
high-quality, unstyled UI components like Radix UI or Headless UI.
Like I said here, this is still a hard problem to solve today:
But thanks to this approach, I feel like we are getting closer to this.
And we might even open source this design system in the future. Stay tuned.
Unifying the codebase by following best practices from both React DOM
and React Native is a great way to move forward. Let's do it!
Thanks to Henry Fontanier, Lois Tatis, Fernando Rojo and Nishan Bende
for reading drafts of this.