Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Design System Adventures in React

Design System Adventures in React

Creating a Design System is a challenge that involves many different disciplines. In this talk, we will specifically explore the technical aspects and see what tools can help implement a React-based Design System. During the talk, we will also see a concrete use case which is the Bento Design System, created and maintained by buildo (https://bento.buildo.io/).

Avatar for Gabriele Petronella

Gabriele Petronella

April 27, 2023
Tweet

More Decks by Gabriele Petronella

Other Decks in Programming

Transcript

  1. Context "Are you't that Scala dude who likes functional programming?"

    Sure, but I spent the last 3 years primarily working on Design Systems!
  2. Pedigree • Built two design systems for Big Enterprise ™

    • Built several others for smaller companies • Distilled that knowledge in Bento, a general purpose design system for the web
  3. Bento • Design System for the web • React +

    TypeScript for the dev library • Figma for the design library • OSS + commercial support • https:/ /bento.buildo.io
  4. A design system is a set of standards to manage

    design at scale by reducing redundancy while creating a shared language and visual consistency across different pages and channels. -- Nielsen Norman Group
  5. Design System shopping list • Foundations • Components • UX

    patterns • Governance • ...whatever works for your context
  6. Vanilla Extract • CSS-in-TS library • CSS fully extracted at

    build time (no runtime overhead) • Similar to CSS modules in spirit • Originally created by Seek for their own DS (Braid) • Successor of treat (https:/ /seek-oss.github.io/treat/)
  7. Why Vanilla Extract (in general)? • Static CSS generation •

    Fewer moving parts, CSS integrates well with any stack • No runtime overhead • Seems more future proof, for example when it comes to SSR (see https:/ /github.com/reactwg/react-18/discussions/108)
  8. Why Vanilla Extract (in general)? • Editor tooling integration •

    It's just TypeScript, no need for special plugins • Styles are TypeScript objects, imports just work
  9. Why Vanilla Extract (for Design Systems)? • Opinionated towards encapsulated

    components • No child selectors allowed const list = style({ padding: 8, selectors: { "& > li": { // ! a style cannot target its children color: "red", }, }, });
  10. Why Vanilla Extract (for Design Systems)? • Styles are objects

    • Easy to manipulate and compose (example: https:/ /github.com/ buildo/bento-design-system/blob/main/packages/bento-design- system/src/Layout/Column.css.ts)
  11. Why Vanilla Extract (for Design Systems)? • Recipes API •

    maps perfectly to how UI components are represented in a design tool
  12. export const buttonRecipe = recipe({ base: { borderRadius: 6, },

    variants: { color: { neutral: { background: "whitesmoke" }, accent: { background: "slateblue" }, }, size: { medium: { padding: 16 }, large: { padding: 24 }, }, }, });
  13. import { buttonRecipe } from "./button.css.ts"; type Props = {

    color: "neutral" | "accent"; size: "medium" | "large"; children: ReactNode; }; function Button({ color, size, children }: Props) { return <button className={buttonRecipe({ color, size })}>{children}</button>; }
  14. Why Vanilla Extract (for Design Systems)? • Sprinkles API •

    generate utility CSS classes • basically a roll-your-own Tailwind... • ...but with a nice API
  15. import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles"; const space =

    { none: 0, small: "4px", medium: "8px" /*...*/ }; const responsiveProperties = defineProperties({ conditions: { mobile: {}, tablet: { "@media": "screen and (min-width: 768px)" }, desktop: { "@media": "screen and (min-width: 1024px)" }, }, defaultCondition: "mobile", properties: { paddingTop: space, paddingBottom: space, // ... }, }); export const sprinkles = createSprinkles(responsiveProperties);
  16. type Props = { children: ReactNode } function Card({ children

    }: Props) { const className = sprinkles({ paddingTop: 'medium' }); return ( <div className={className}> {children} </div> );
  17. type Props = { children: ReactNode } function Card({ children

    }: Props) { const className = sprinkles({ paddingTop: { desktop: 'large', tablet: 'medium', mobile: 'small' } }); return ( <div className={className}> {children} </div> );
  18. Why Vanilla Extract (for Design Systems)? • Sprinkles API •

    nice to expose foundations to consumers
  19. Root cause A string is a string ! <MyComponent label="Foo

    foo foo" /> <MyComponent label={t('Whatever.submitButtonLabel')} />
  20. Can we do better? function MyComponent({ label }: { label:

    string }) { // ... } ⬇ function MyComponent({ label }: { label: LocalizedString }) { // ... }
  21. LocalizedString? This doesn't work: type LocalizedString = string // transparent

    type alias We need something "more" than string: type LocalizedString = string & ???
  22. LocalizedString as a branded type type LocalizedString = string &

    { readonly __brand: unique symbol }; The type is string but also something more.
  23. LocalizedString as a branded type declare const normalString: string; declare

    const label: LocalizedString; declare function giveMeAString(s: string); declare function giveMeALocalizedString(s: LocalizedString); giveMeAString(normalString); // OK giveMeAString(label); // OK giveMeALocalizedString(label); // OK giveMeALocalizedString(normalString); // Error
  24. Back to our component function MyComponent({ label }: { label:

    LocalizedString }) { // ... } <MyComponent label="Woops, not localized, TypeScript complains" /> <MyComponent label={t('Whatever.submitButtonLabel')} />
  25. How do we create a LocalizedString? Well... by casting! Isn't

    casting bad? In general, yes. In this case, it's fine if confined to a single function. In practice you wrap the localization function of a library like react- i18next or react-intl, to change the return type to LocalizedString instead of string.
  26. Ok, but I don't want any of this! What if

    I'm a library and I don't want to force this mechanism on my users? Ideal scenario: - the library accepts string by default - the consume can opt-in into stricter type-safety via LocalizedString
  27. Declaration merging interface Box { height: number; } interface Box

    { width: number; } same as interface Box { height: number; width: number; }
  28. Declaration merging cannot override members interface Box { height: number;

    } interface Box { height: string; // ❌ Subsequent property declarations // must have the same type }
  29. Module augmentation Imagine mymodule exports a Box interface like: interface

    Box { height: number; } We can "augment it" with a module augmentation: declare module "mymodule" { // module augmentation interface Box { // declaration merging width: number; } }
  30. Putting it all together In our library we define an

    empty interface meant to be "merged": export interface TypeOverrides {} we then define the "base" types: interface BaseTypes { LocalizedString: string; }
  31. Putting it all together We derive our final types as

    import { O } from "ts-toolbelt" type ConfiguredTypes = O.Overwrite<BaseTypes, TypeOverrides> // This is the type we use in our library export type LocalizedString = BaseTypes['LocalizedString'] & ConfiguredTypes['LocalizedString'] // Our beloved stricter type export type StrictLocalizedString = string & { readonly __brand: unique symbol }
  32. Putting it all together Now a consumer of the library

    can opt-in into stricter type-safety by augmenting the TypeOverrides interface: import { StrictLocalizedString } from "mylibrary"; declare module "mylibrary" { interface TypeOverrides { LocalizedString: StrictLocalizedString } }
  33. To recap • Branded type to avoid mixing strings and

    localized strings • Declaration merging + module augmentation to allow configuring library type from outside