What we’re trying to achieve
We want to introduce a new version of MacawUI which will be a design system. The current version has a few problems:
- Macaw UI is using an outdated version of MUI (using the current Macaw + MUI often requires writing some hacks).
- Moving out of component library constraints requires a lot of work.
- Macaw and MUI are quite slow by today’s standards, it takes a while to load on fresh render (especially noticeable in apps).
Describe a proposed solution
Introduce a design system consisting of 3 parts:
- design primitives
- UI components
- documentation
What are the goals of the new version of Macaw UI
- 3rd party applications using embedded in the dashboard should comply with the theme
- Should support UI components for Dashboard, Cloud, and Apps projects.
- 3rd party developers should have access to components and design primitives to ensure that their layout and custom components comply with the theme
- As the macaw theme evolves, 3rd party applications should be backward compatible with the theme
- We can make assumptions about the stack of the 3rd party developers. Therefore there should be a framework-agnostic way to expose the design system, for example, JS dictionary, CSS variables, etc.
Design primitives
Design primitives (tokens) should be the foundation of the Design System. We will have primitives for:
- Typography
- Spacing
- Colors
- Shadows
- Border radiuses
Warning
Code examples are created with CSS calc to demonstrate scales but in implementation, we are going to calculate variables inside JS and expose ready values as CSS variables.
Specific values of tokens can be adjusted by the theme creator - in this RFC we want to show the approach to managing and defining them.
Typography
- Font: Rubik.
- Font size: calculated as a scale from base font size multiplied by scale. For a visual representation of scale see type-scale.com.
- Line height: two values for headings and texts.
CSS variables example:
:root {
--font-size-scale: 1.2;
--font-size-1: 0.75rem;
--font-size-2: calc(var(--font-size-1) * var(--font-size-scale));
--font-size-3: calc(var(--font-size-2) * var(--font-size-scale));
--font-size-4: calc(var(--font-size-3) * var(--font-size-scale));
--font-size-5: calc(var(--font-size-4) * var(--font-size-scale));
--font-size-6: calc(var(--font-size-5) * var(--font-size-scale));
--font-size-7: calc(var(--font-size-6) * var(--font-size-scale));
--font-size-8: calc(var(--font-size-7) * var(--font-size-scale));
--font-size-9: calc(var(--font-size-8) * var(--font-size-scale));
--font-size-10: calc(var(--font-size-9) * var(--font-size-scale));
--line-height-heading: 1.5;
--line-height-text: 1.2;
}
Spacing
Handpicked by us. CSS variables example:
:root {
--space-1: 0.0625rem;
--space-2: 0.125rem;
--space-3: 0.25rem;
--space-4: 0.5rem;
--space-5: 0.75rem;
--space-6: 1rem;
--space-7: 1.25rem;
--space-8: 1.5rem;
--space-9: 2rem;
--space-10: 2.5rem;
--space-11: 3rem;
--space-12: 4rem;
--space-13: 5rem;
}
Colors
Hand-picked by us based on color scale generator. MacawUI will then map values from palettes into specific colors for component variants and states. You can see an example mapping on the diagram below.
Image 1: Mapping between design primitives for colors.
Image 2: Example of default palette.
Image 2: Example of brand palette.
CSS variables:
:root {
/* values from the palettes */
--text-primary-foreground: hsla(210, 60%, 60%, 1);
--button-primary-foreground: hsla(215, 96%, 43%, 1);
--button-primary-background: hsla(215, 84%, 73%, 1);
--button-primary-background-active: hsla(215, 94%, 58%, 1);
--button-primary-background-hover: hsla(215, 100%, 51%, 0.08);
--button-primary-background-focus: hsla(215, 100%, 51%, 0.12);
--button-secondary-foreground: hsla(215, 97%, 35%, 1);
}
Shadows
Picked by us. CSS variables:
:root {
--shadow-1: 0 26px 80px hsla(0, 0, 0, 0);
--shadow-2: 0 26px 100px hsla(0, 0, 0, 0);
--shadow-3: 0 26px 80px hsla(0, 0, 0, 0);
}
Border radiuses
Picked by us. CSS variables:
:root {
--border-radius-1: 2px;
--border-radius-2: 4px;
--border-radius-3: 6px;
}
Final tokens
:root {
--font-size-1: 0.75rem;
--font-size-2: calc(var(--font-size-1) * var(--font-size-scale));
--font-size-3: calc(var(--font-size-2) * var(--font-size-scale));
--font-size-4: calc(var(--font-size-3) * var(--font-size-scale));
--font-size-5: calc(var(--font-size-4) * var(--font-size-scale));
--font-size-6: calc(var(--font-size-5) * var(--font-size-scale));
--font-size-7: calc(var(--font-size-6) * var(--font-size-scale));
--font-size-8: calc(var(--font-size-7) * var(--font-size-scale));
--font-size-9: calc(var(--font-size-8) * var(--font-size-scale));
--font-size-10: calc(var(--font-size-9) * var(--font-size-scale));
--line-height-heading: 1.5;
--line-height-text: 1.2;
--space-1: 0.0625rem;
--space-2: 0.125rem;
--space-3: 0.25rem;
--space-4: 0.5rem;
--space-5: 0.75rem;
--space-6: 1rem;
--space-7: 1.25rem;
--space-8: 1.5rem;
--space-9: 2rem;
--space-10: 2.5rem;
--space-11: 3rem;
--space-12: 4rem;
--space-13: 5rem;
--text-primary-foreground: hsla(210, 60%, 60%, 1);
--button-primary-foreground: hsla(215, 96%, 43%, 1);
--button-primary-background: hsla(215, 84%, 73%, 1);
--button-primary-background-active: hsla(215, 94%, 58%, 1);
--button-primary-background-hover: hsla(215, 100%, 51%, 0.08);
--button-primary-background-focus: hsla(215, 100%, 51%, 0.12);
--button-secondary-foreground: hsla(215, 97%, 35%, 1);
--shadow-1: 0 26px 80px hsla(0, 0, 0, 0);
--shadow-2: 0 26px 100px hsla(0, 0, 0, 0);
--shadow-3: 0 26px 80px hsla(0, 0, 0, 0);
--border-radius-1: 2px;
--border-radius-2: 4px;
--border-radius-3: 6px;
}
The code snippet above presents CSS variables that act as a theme contract. If a developer wants to create a new theme they need to use those variables and fulfill the contract. Contract variables should not change.
Changing from one to another theme (e.g light to dark) will use JS API to set CSS variables on root
HTML element:
const defaultLightTheme = {
"--font-size-1": "0.75rem",
"--font-size-2": "0.9rem",
"--font-size-3": "1.08rem",
"--font-size-4": "1.296rem",
"--font-size-5": "1.5552rem",
"--font-size-6": "1.86624rem",
"--font-size-7": "2.23949rem",
"--font-size-8": "2.68739rem",
"--font-size-9": "3.22486rem",
"--font-size-10": "3.86984rem",
"--line-height-heading": "1.5",
"--line-height-text": "1.2",
"--space-1": "0.0625rem",
"--space-2": "0.125rem",
"--space-3": "0.25rem",
"--space-4": "0.5rem",
"--space-5": "0.75rem",
"--space-6": "1rem",
"--space-7": "1.25rem",
"--space-8": "1.5rem",
"--space-9": "2rem",
"--space-10": "2.5rem",
"--space-11": "3rem",
"--space-12": "4rem",
"--space-13": "5rem",
"--text-primary-foreground": "hsla(210, 60%, 60%, 1)",
"--button-primary-foreground": "hsla(215, 96%, 43%, 1)",
"--button-primary-background": "hsla(215, 84%, 73%, 1)",
"--button-primary-background-active": "hsla(215, 94%, 58%, 1)",
"--button-primary-background-hover": "hsla(215, 100%, 51%, 0.08)",
"--button-primary-background-focus": "hsla(215, 100%, 51%, 0.12)",
"--button-secondary-foreground": "hsla(215, 97%, 35%, 1)",
"--shadow-1": "0 26px 80px hsla(0,0,0,0)",
"--shadow-2": "0 26px 100px hsla(0,0,0,0)",
"--shadow-3": "0 26px 80px hsla(0,0,0,0)",
"--border-radius-1": "2px",
"--border-radius-2": "4px",
"--border-radius-3": "6px",
};
const root = document.documentElement;
Object.entries(defaultLightTheme).map(([key, value]) =>
root.style.setProperty(key, value)
);
UI Components
Components will use second-level CSS variables for colors and first-level variables for the rest of the properties. This allows us to have more control in applying different themes. CSS variables will be defined in the global CSS variables space (:root
). For example Text
and Button
components will have the following variables:
:root {
/* 1st level of variables */
--font-size-1: 1rem;
--line-height-heading: 1.5;
/* 2nd level of variables */
--text-primary-foreground: hsla(210, 60%, 60%, 1);
--button-primary-foreground: hsla(215, 96%, 43%, 1);
--button-primary-background: hsla(215, 84%, 73%, 1);
--button-primary-background-hover: hsla(215, 100%, 51%, 0.08);
--button-secondary-foreground: hsla(215, 97%, 35%, 1);
}
.text {
font-size: var(--font-size-1);
line-height: var(--line-height-heading);
color: var(--text-primary-foreground);
}
.buttonPrimary {
color: var(--button-primary-foreground);
background-color: var(--button-primary-background);
}
.buttonPrimary:hover {
background-color: var(--button-primary-background-hover);
}
.buttonSecondary {
color: var(--button-secondary-foreground);
}
Image 3: Mappings between UI components and design tokens.
API interface for using components will be created using TypeScript interfaces. The component library will be created as React components. If a component is simple like a button or text we should expose all props. Following the text example we will have:
interface TextProps extends React.ComponentPropsWithoutRef<"p"> {
variant: "base" | "heading" | "subheading";
weight: "normal" | "bold";
as: "h1" | "p";
children: React.Node;
}
// Later in the code
<Text variant="base" weight="normal" as="h1">
Short sleeve T-Shirt
</Text>;
Note
UI components API shouldn’t expose the internals of the design system e.g what library we use to build component primitives or what tool we are using as CSS foundation.
Storybook
Will be responsible for visual testing. It will work as documentation in the first stages of the library.
How do we want to achieve a proposed solution
Repository structure and migration process
We can use a single-repo setup with the following directories:
src/
- holds the newly created components (new macaw)
legacy/
- holds the whole legacy macaw
.storybook/
- configuration of storybook
dist/
- build output
You can see an example repository structure for CSS Modules
$ tree macaw-ui
macaw-ui
├── dist
├── legacy
├── .storybook
└── src
├── index.ts
├── styles
│ ├── globals.css
│ ├── index.css
│ └── reset.css
├── text
│ ├── index.ts
│ ├── text.module.css
│ ├── text.spec.ts
│ ├── text.stories.tsx
│ └── text.tsx
└── theme
├── index.ts
└── provider.tsx
Given that we have two outputs: old macaw (legacy directory) and new macaw (src directory), we can present them as separate sections within the storybook:
Image 4: Storybook with two sections: legacy and new macaw.
Note
Why not use monorepo? We need to keep it as simple as possible. More complexity, harder to maintain as well as can put a hurdle for contributors. Additionally, monorepos are designed for having multiple packages, that are shared with each other, while in macaw we share just one library and some addons, such as docs or storybook.
Example: macaw-ui/single-repo-concept (with pnpm)
Shipping and exposing bundled code
This is where the problems pop up. Since our library now exposes old and new components, undoubtedly we will face naming conflicts (eg. both versions expose a component named Button). To address it, we essentially have the following options:
Using exports and having two entry points defined
We can define the following entry points definition within package.json
"exports": {
"./next": {
"import": "dist/src/index.mjs",
"require": "dist/src/index.js"
},
".": {
"import": "dist/legacy/index.mjs",
"require": "dist/legacy/index.js"
}
},
After installing macaw-ui everything will work as previously, but whenever we want to use new components we will use @saleor/macaw-ui/next
instead of @saleor/macaw-ui
import { Button as ButtonOld }, mc from "@saleor/macaw-ui"
import { Button as ButtonNew }, mc from "@saleor/macaw-ui/Next"
<ButtonOld>Legacy button</ButtonOld>
<ButtonNew>New button</ButtonNew>
Pros:
- transparent migration, we don’t have to change anything within the code, we can simply start using new components
- explicitly picking newly created components
- easy to remove from the codebase, once we deprecate all of the legacy components we can simply remove them
Cons:
- explicit entrypoint, we will have to remove it from the entire codebase of the projects, once we fully migrate to the new macaw (changing importy from
@saleor/macaw-ui/next
to @saleor/macaw-ui
everywhere in the project!).
Using an object as a namespace
We can expose everything that is legacy as it is right now, but newly created components under the namespace eg. mc
import { Button }, mc from "@saleor/macaw-ui"
<Button>Legacy button</Button>
<mc.Button>New button</mc.Button>
Pros:
- fully transparent migration, no more changes needed afterward
- the explicit distinction between components within the projects and atomic components imported from the component library package
Cons:
- the tree-shaking problem, object (namespace) cannot be tree shakable (is there any workaround?)
Using prefixed name
We can use prefixes for the newly created components:
import { Button, mcButton } from "@saleor/macaw-ui"
<Button>Legacy button</Button>
<mcButton>New button</mcButton>
Where the mc
is the prefix
Pros:
- fully transparent migration, no more changes needed afterward
- the explicit distinction between components within the projects and atomic components imported from the component library package
- three shaking
Cons:
Using separated repo and package name
We can simply create a new repository with a different package name it addresses all of the problems mentioned above, but that means sort of rebranding.
UI components - technology
We have 3 different approaches to technology that will be used:
CSS Modules
- Pros
- No additional tooling needed
- Easy to get started
- Supported out of the box with many React tooling e.g Next.js or CRA
- Cons
- You need to convert JS vars to CSS vars
- A lot of pure CSS boilerplate
- Nested selectors
- Media queries
- Additional tooling needed for removing not used CSS
- Example: macaw-css-modules
Tailwind CSS
- Pros
- Easy to create breakpoints values
- Used in many places
- Utility classes generated by default
- Cons
- Colors can be used for every value e.g
background-color
, text-color
, border-color
which can be problematic as we have colors for backgrounds only, etc - we lost semantics
- You need to create a few hacks to map component level API e.g
size={1}
to gap-1
- Example: macaw-tailiwind-css
Vanilla Extract
- Pros
- Theming of out the box
- Easy to create variants
- Easy to create breakpoints values
- Cons
- Needs plugins for webpack/vite etc
- Additional tooling needed for removing not used CSS
- New library
- Example: macaw-vanilla-extract
Additional links
Examples