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

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