awesome-ds
A personal collection of resources, notes, and references for Design Systems
Table of Contents
- Design Language
- Design Tokens
- Theming
- Components
- Layouts
- Styling
- React
- Releases
- Community
- Testing
- Measuring Success
- Taxonomy
- Links & Resources
Design Language
Areas
- Color scales
- Typography
- Motion
- Spacing
- Layout
- Icons
Tooling
Icons
Tooling
Design Tokens
Theming
Topics
- Light & Dark mode
- Setting user preference
- Avoiding flash of theme switch if loading in via JavaScript (support SSR use-case)
- Conditional rendering of items based on theme (illustrations)
- Setting user preference
Components
Gotchas
- When using a modal dialog,
<header>
and<footer>
semantics are not stripped if the dialog is appended to the body - When using some form of pop-up, if it is positioned in DOM order it will be cut off if a parent has overflow hidden
- When using
localStorage
, it may be helpful to wrap calls to prevent errors if a user has this disabled in their browser - When using a key event (like Escape, Home, PageUp, PageDown, Arrow keys) make sure to stop propagation and prevent default if you do not want a scroll to occur for some keys
Lifecycle
- Unstable
- Stable
- Intermediate stages
- Evolving a stable component
- Adding functionality
- Removing functionality
- Deprecation cycle
- Deprecate in current major release, notify of support policy
- Remove in either next release or n + 1 release
- Deprecation cycle
- Tools
- Codemods
Testing
- Areas
- Visual
- Visual Regression Testing
- Behavior
- User interactions
- States
- Accessibility
- Automated checks
- Manual checks
- Types
- Unknown type is not valid
- Types autocomplete correctly
- Types are sufficiently narrow
- Visual
Implementation
Persistence
- Local storage
Hidden
This component is useful to abstract the relationship between the visibility of
a group of content and breakpoints for a Design System. It may also be helpful
to provide this functionality through a custom hook, such as useMedia
,
useBreakpoints
, etc.
It's important that visibility as it relates to layout or content should be constrained to CSS. This minimizes layout shift if the page is Server-Side Rendered and avoids jumps of content becoming visible or hidden as JavaScript is loaded on the page.
API Design
function Example() {
return (
<>
<Hidden when="sm">
<SomeContent />
</Hidden>
<Hidden when={['sm', 'md']}>
<SomeContent />
</Hidden>
<Hidden above="sm">
<SomeContent />
</Hidden>
<Hidden below="sm">
<SomeContent />
</Hidden>
<Hidden breakpoint="sm">
<SomeContent />
</Hidden>
</>
);
}
Links & Resources
Examples
Layouts
Layouts in a design system define the common structure and presentation of your product. They create consistent and predictable structure to pages and foster cohesion across page and product boundaries.
Providing common layouts provide a way to quickly build pages with similar data requirements. Providing a foundational grid system allows any layout to feel like it belongs in the family of products even if it does not use a pre-defined layout.
Some great layouts and corresponding components to get started with include:
- A general grid system, with Grid and Column components
- Subgrid support is a plus
- Vertical rhythm support through a Stack component
- Pancake and Holy grail layouts
- Side-nav and panel placement layouts
- Flex components that derive gap based on spacing system
Techniques
- CSS Grid, grid areas
- N column Grid
Links & Resources
- https://web.dev/one-line-layouts/
- https://every-layout.dev/
- https://ryanmulligan.dev/blog/layout-breakouts/
- https://www.joshwcomeau.com/css/full-bleed/
- Grid
Styling
Approaches
- Native CSS
- CSS Modules
- Sass
- CSS-in-JS
Topics
- Runtime vs zero runtime styling approaches
Techniques
- Linting for specific properties in style to make sure they come from design language or tokens
React
API Design
Approaches
- Kitchen-sink
- Pros
- Easy to coordinate complex behavior
- Able to swap out implementation without breaking changes
- Cons
- Lack of flexibility
- Pros
- Structural
- Pros
- Maximum flexibility
- Cons
- Hard to build in behavior, requires orchestration
- Pros
- Layouts
- Behavior
- Styling
- Async
Questions
- When do you add to a component versus create another component?
- Are you adding multiple props? Do they only work with each other and don't make sense without each other?
Changes
Category | Situation | Semver |
---|---|---|
Children | Change from a wrapping element to a fragment | |
Change from fragment to wrapping element | ||
Change node type of wrapping element | ||
Props | A prop is added | minor |
A prop is removed | major | |
A prop is renamed (make sure to follow deprecation flow) | major | |
The propType broadens or accepts more types | minor | |
The propType narrows or accepts fewer types | major | |
Refs | Add a ref to a component | minor |
Remove a ref from a component | major | |
Move where a ref is placed within a component | ||
Styles | A class name, style data attribute, etc is added | |
The position or display value of an element changes | ||
The position of a parent element changes (e.g. changes to position: relative |
||
Types | A type is added | |
The type is removed | ||
The type broadens or accepts more types | ||
The type narrows or accepts fewer types |
Questions
- When to add to a component versus creating a new component?
- If the props that are being added can exist independently, it might be useful to bake into an existing component
- If props being added are coupled, in other words they don't only exist for each other and specifying one without the other doesn't make sense, it might be worth exploring a new component that combines these concepts
Components
Stable prop callbacks
A component may accept callback functions as props, such as an onChange
, that
is invoked in response to an action or event.
function ExampleComponent({ onClick }) {
return (
<button type="button" onClick={() => onClick()}>
Example
</button>
);
}
At times, this handler may need to be referenced in an effect or in other primitive that requires a dependency array. This may result in the following implementation:
function ExampleComponent({ onChange }) {
// ...
useEffect(() => {
onChange(state);
}, [state, onChange]);
// ...
}
In this situation, the effect will fire each time the state
value updates but
also each time the onClick
function reference changes. If you're expecting
consumers to use values like an arrow function expression, this may lead to
unexpected work being performed.
<ExampleComponent
onChange={() => {
/* ... */
}}
/>
There may also be a correctness aspect to this, as well, in that the effect
should not be synchronizing with the identity of the onChange
function and is
firing unexpected as a result. For example, a ResizeObserver
implementation
may look like:
function ExampleComponent({ onResize }) {
const ref = useRef();
useEffect(() => {
const observer = new ResizeObserver((entries) => {
// ...
onResize();
});
const { current: element } = ref;
observer.observe(element);
return () => {
observer.disconnect();
};
}, [onResize]);
// ...
}
In this situation, the useEffect
will run each time the reference of
onResize
changes. As a result, the callback to ResizeObserver
will also call
onResize
as this is the default behavior of this primitive. Depending on what
onResize
does when called, this may lead to an infinite loop as the state of
the parent changes which in turn causes the onResize
to change, which then
updates the useEffect
, and finally the ResizeObserver
callback before
looping again.
To address this, you can save the value of the callback into a ref:
function ExampleComponent({ onChange }) {
const savedOnChange = useRef(null);
// ...
useEffect(() => {
savedOnChange.current = onChange;
});
useEffect(() => {
savedOnChange.current?.(state);
}, [state]);
// ...
}
This allows you to always have the latest reference to onChange
while still
having a stable reference in the effect as to avoid adding it to the depenendecy
array.
Stable default values
A component may provide a default value for a prop
as a default parameter.
function ExampleComponent({ onChange = () => {}, foo = {}, bar = [] }) {
// ...
}
Historically, default props may also be defined as defaultProps
on the
component function.
function ExampleComponent() {
// ...
}
ExampleComponent.defaultProps = {
onChange: () => {},
foo: {},
bar: [],
};
When moving from defaultProps
to default parameters, there is a change in the
reference of a value. Specifically, with defaultProps
values will have the
same reference for complex types like functions, objects, arrays, etc. When used
as a default parameter, however, these values will no longer have a stable
reference as they are re-created each render.
Most of the time, this will not be an issue. However, if you are using these values in dependency arrays or notice components unnecessarily re-rendering when provided these values then you can move these values to the top-level scope to retain the same reference semantics as before.
const defaultOnChange = () => {};
const defaultFoo = {};
const defaultBar = [];
function ExampleComponent({
onChange = defaultOnChange,
foo = defaultFoo,
bar = defaultBar,
}) {
// ...
}
Working with timeouts
Use a wrapper hook like useTimeout
when working with
setTimeout
inside of a component. This provides safety so that you can
guarantee that no setTimeout
function will run after a component unmounts.
Hooks
ref
instead of creating and returning one
Prefer accepting a Unpreferred | Preferred |
---|---|
const { ref } = useExample() |
const ref = useRef(null);
useExample(ref) |
When designing hooks that require a reference to a DOM node (using a ref
) you
should design the hook to take in a ref
as an argument instead of creating a
ref
on behalf of the caller.
This is important when a caller decides to use multiple hooks that rely on a
ref
. For example,
function MyComponent() {
const [ref1, isHovering] = useHover();
const [ref2, isDragging] = useDrag();
// How should the caller merge these two refs?
}
If, instead, these hooks took in a ref
we could have the caller manage the
ref
and pass it into the hooks.
function MyComponent() {
const ref = useRef(null);
const isHovering = useHover(ref);
const isDragging = useDrag(ref);
// Caller has to add `ref` to a node below
}
useCallback
and useMemo
Using useCallback
and useMemo
can be incredibly useful tools in certain
situations. In general, however, we try to avoid them unless one of the
following conditions occur:
- The identity of a function or object is required as a dependency in a dependency array
- We have observed performance issues due to allocations that can be reproduced and resolved using these techniques
This practice is to avoid introducing useCallback
and useMemo
prematurely,
which can create extra work for our components to perform.
A rule of thumb for this is to understand how frequently a dependency will
update that is given to useCallback
or useMemo
. If a dependency is likely to
update frequently, then React will have to perform comparisons and re-run
callback to useCallback
and useMemo
. This would be slower than creating a
new function each render instead.
Prefer returning event handlers versus adding them
When working with hooks that listen to events like onKeyDown
, onClick
, etc
you have two options for how to bind logic to these events:
-
Return a function that the caller can attach to their component
function MyComponent() { const { onKeyDown } = useCustomHook(); return <CustomComponent onKeyDown={onKeyDown} />; }
-
Accept a
ref
and calladdEventListener
in an effectfunction MyComponent() { const ref = React.useRef(null); useCustomHook(ref); return <CustomComponent ref={ref} />; } // useCustomHook.js function useCustomHook(ref) { React.useEffect(() => { function listener(event) { // ... } const { current: node } = ref; node.addEventListener('keydown', listener); return () => { node.removeEventListener('keydown', listener); }; }, []); }
-
Pros of returning a function
- The caller has control over when this behavior is applied
- It's clear, especially with multiple handlers for the same event, what is being called
-
Cons of returning a function
- The caller is responsible for placing the function on the correct element
- Changing the event requires a breaking change (or an intermediate layer that obfuscates props)
-
Pros of accepting a
ref
- You can swap out the implementation or event without a Public API change
-
Cons of accepting a
ref
- It's difficult to enable or disable this behavior due to conditional hooks (would likely need an option or flag passed to turn off)
- It can hide the fact that a keydown event is being registered on the element
- The caller is responsible for creating a ref and placing it on the correct element
Refs
useMergedRefs
when using forwardRef
Prefer using const ExampleComponent = React.forwardRef(function ExampleComponent(
props,
forwardRef
) {
const inputRef = React.useRef(null);
const ref = useMergedRefs(forwardRef, inputRef);
// ...
return <input ref={ref} type="text" />;
});
Testing
Recipes
useEvent
import * as React from 'react';
function useEvent(elementOrRef, name, callback) {
const ref = React.useRef(null);
React.useEffect(() => {
ref.current = callback;
});
React.useEffect(() => {
const element = elementOrRef.current ?? elementOrRef;
function listener(event) {
ref.current?.(event);
}
element.addEventListener(name, listener);
return () => {
element.removeEventListener(name, listener);
};
}, [elementOrRef, name]);
}
useMergedRefs
import * as React from 'react';
function useMergedRefs(...refs) {
return React.useCallback((node) => {
for (const ref of refs) {
if (!ref) {
continue;
}
if (typeof ref === 'function') {
ref(node);
} else if (typeof ref === 'object') {
ref.current = node;
}
}
}, []);
}
useStableCallback
aka useSavedCallback
import * as React from 'react';
function useStableCallback(callback) {
const ref = React.useRef(null);
React.useEffect(() => {
ref.current = callback;
});
return React.useCallback((...args) => {
return ref.current?.(...args);
}, []);
}
useTimeout
import * as React from 'react';
export function useTimeout() {
const timers = React.useRef(null);
if (timers.current === null) {
timers.current = new Set();
}
React.useEffect(() => {
return () => {
for (const id of timers.current) {
clearTimeout(id);
}
timers.current.clear();
};
}, []);
return React.useCallback((fn, delayMs) => {
const timeoutId = setTimeout(() => {
fn();
timers.current.delete(timeoutId);
}, delayMs);
timers.current.add(timeoutId);
return timeoutId;
}, []);
}
Releases
Packages
- Release cadency
- Merge
- Time-based
- On-demand
- Versioning
- Prerelease (0.x)
- Semver (1.x)
- Multi-package vs single package
- Bundling
- Side-effects
package.json
exports
- Breaking changes
- Prerelease packages
- Separate changes into branch or feature flag
Community
Proposals
Support
Areas
- Slack
- GitHub issues
- GitHub discussions
- Office hours
- Design crits
SLA
- What level of support do you provide for teams?
- When can they expect to hear back from you when they make an issue?
- How long should they expect it take to address an issue?
- How do teams stay engaged with updates?
- What level of support should teams expect from contributions or proposals?
Contribution
Testing
It's important to test the API and the implementation of what is delivered in a design system. Testing the API ensures that you're not accidentally shipping a breaking change to the code that you're shipping for developers to use. Testing the implementation makes sure that the appearance and behavior of the component does not change unexpected for end users (or for developers using the design system who may be relying on that behavior).
It's also important to include certain structural and automated tests to make sure that the foundations of the design system (like tokens or the underlying design language or brand) setup teams for success by shipping accessible primitives.
Black box
When considering how to test components or aspect of your design system, it is helpful to consider the overall interface and ways in which a developer or user interacts with your component. These different factors make up the overall contract of your component and should be what you prioritize in your testing strategy. In addition, it's helpful to frame tests from this "black box" model in order to allow you to make changes as needed to the internal structure of a component or module.
As a rough guide, here are some things that are useful to incorporate into your testing strategy:
- The API of your component, how does a developer use this in their project?
- How does an end-user interact with this component?
- Make sure to consider mouse and keyboard and test with both
- If a specific role or
aria-*
property is necessary for a screen reader, make sure this is also tested
- What is the expected visual appearance of this component across themes? (Visual Regression Testing)
- Anything related to how your team defines semver that you want to make sure is
captured (for example, changing the location of a
ref
if you're writing a React component)
It is also helpful to include certain automated checks, like color contrast for design color tokens or automated accessibility checks, in your testing strategy.
Automated checks
- Make sure all possible design color token combinations meet contrast requirements
- Run an accessibility checker to catch common issues, axe or Accessibility Checker are great tools to get started
Measuring Success
- Metrics: what you can measure in order to drive insights and observe leading
and lagging indicators
- Instance count of your components
- Usage count of props for your components
- Number of lint overrides, escape hatches used when building a component (like custom styling)
- Research
- Complement metrics with user research/interviews in order to identify pain points, common user journeys, and ideas for future work that is important to the business
- As a library author1
- How successful all users are in using the API
- The quality of the output that users achieve by using the API
- The percentage of users making the correct choices
- As an infrastructure team
- From the business
- Does the company have a cohesive look and feel? (And why does this matter to your company?)
- How much work is shared across the company, and how quickly do updates permeate through the system (this measures how quickly can your company adapt or change)
- How quickly can product teams implement a feature, build out a product, etc. (how quickly can a team add value using company tools)
Taxonomy
Ways to classify different parts of your design system, often times this is helpful for components when you have multiple framework implementations or a decentralized system of systems.
Component
type: component
name: string
status: unstable | stable | ...
implementation: react | rails | web-components | ...
links:
- type: docs
title: Storybook
url: ...
Icons
type: icon
name: string
aliases: []
sizes: []
categories: []
deprecated: boolean
reason: string