Creating Zest Components for Web
Guide for creating new components in the Zest design system for web applications (packages/zest/src/ in the web repository).
When to Use This Skill
Use this skill when:
- •Creating a new Zest component from Figma designs
- •Adding a new variant to an existing Zest component
- •Building reusable UI components for the web design system
- •Implementing components that will be used across multiple features/brands
Core Principles
Components live in packages/zest/src/: All Zest components for web are in the packages/zest/src/ directory within the web monorepo. This is the single source of truth for the Zest design system on web.
Use BoxWithNewTokens: Build new components using BoxWithNewTokens (imported as Box). This provides support for the new component token system.
TypeScript is required: All components must be written in TypeScript with proper type definitions using Variant<UseNewTokens>[] for variants.
Component tokens for styling: Use component-level design tokens (e.g., 'components.badge.color.neutral.background') rather than global tokens where possible.
Accessibility is mandatory: All components must be accessible (semantic HTML, ARIA attributes, keyboard navigation, focus management).
Theme tokens only: Never hardcode colors, spacing, or typography. Always use design tokens from the token system.
File Structure
Component Directory Structure
packages/zest/src/
└── MyComponent/
├── MyComponent.tsx # Main component file (or index.tsx for simple components)
├── types.ts # TypeScript interfaces and prop types
├── styles.ts # Default styles as BoxProps
├── variants.ts # Variant definitions using Variant<UseNewTokens>[]
├── MyComponent.test.tsx # Component tests
└── index.ts # Barrel export
Multi-Part Component Structure (e.g., NumberStepper, Accordion)
packages/zest/src/
└── NumberStepper/
├── index.ts # Object.assign export pattern
├── Container.tsx # Main container component
├── ValueContainer.tsx # Sub-component
├── Buttons/ # Sub-component directory
│ ├── index.tsx
│ ├── DecrementButton.tsx
│ └── IncrementButton.tsx
├── types.ts
├── variants.ts
├── styles.ts
├── NumberStepper.spec.tsx
├── NumberStepper.interactions.spec.tsx
└── NumberStepper.accessibility.spec.tsx
Example: Creating a Badge Component (Based on Real Badge Implementation)
types.ts:
// packages/zest/src/Badge/types.ts
import type { Icons16 } from '@/libs/zest-support/icons';
import type { CSSProperties } from 'react';
type Size = 'xs' | 'sm';
export type BadgeProps = {
/**
* Visual variant of the badge
* @default 'neutral'
*/
variant?: 'neutral' | 'reward' | 'error' | 'success' | 'information' | 'warning';
/**
* Badge content - string or icon
*/
content?: string | React.ReactElement<Icons16>;
/**
* Size of the badge
* @default 'xs'
*/
size?: Size | Size[];
/**
* Show/hide stroke border
* @default 'hide'
*/
stroke?: 'show' | 'hide';
} & Omit<React.HTMLAttributes<HTMLDivElement>, keyof CSSProperties | 'style'>;
variants.ts:
// packages/zest/src/Badge/variants.ts
import type { Variant, UseNewTokens } from '@/libs/zest-support';
const variants: Variant<UseNewTokens>[] = [
{
prop: 'variant',
variants: {
neutral: {
bg: 'components.badge.color.neutral.background',
color: 'components.badge.color.foreground',
},
error: {
bg: 'components.badge.color.negative.background',
color: 'components.badge.color.foreground',
},
success: {
bg: 'components.badge.color.positive.background',
color: 'components.badge.color.foreground',
},
reward: {
bg: 'components.badge.color.reward.background',
color: 'components.badge.color.foreground',
},
information: {
bg: 'components.badge.color.information.background',
color: 'components.badge.color.foreground',
},
warning: {
bg: 'components.badge.color.warning.background',
color: 'components.badge.color.foreground',
},
},
},
{
prop: 'badgeSize',
variants: {
xs: { minWidth: '1rem', height: '1rem' },
sm: { minWidth: '1.5rem', height: '1.5rem' },
},
},
{
prop: 'stroke',
variants: {
show: { borderWidth: '2px', borderStyle: 'solid', borderColor: 'global.white' },
hide: {},
},
},
];
export default variants;
styles.ts:
// packages/zest/src/Badge/styles.ts
import type { BoxProps } from '../Box/BoxWithNewTokens';
export default {
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 'components.badge.border-radius.default',
} as BoxProps;
Badge.tsx:
// packages/zest/src/Badge/Badge.tsx
import { forwardRef, Ref } from 'react';
import { BoxWithNewTokens as Box } from '../Box';
import Text from '../Text';
import variants from './variants';
import defaultStyles from './styles';
import type { BadgeProps } from './types';
/**
* ### Badge
* Displays a small status indicator or count.
*
* See the [docs](https://www-staging.yourcompany.com/zest-docs/Badge) for more information.
*
* #### Usage
* ```js
* import { Badge } from '@/libs/zest';
*
* return (
* <Badge variant="success" size="sm">
* 3
* </Badge>
* )
* ```
*/
const Badge = forwardRef((props: BadgeProps, ref?: Ref<HTMLDivElement>) => {
const {
variant = 'neutral',
size = 'xs',
stroke = 'hide',
content,
...rest
} = props;
return (
<Box
{...defaultStyles}
variants={variants}
variant={variant}
badgeSize={size}
stroke={stroke}
ref={ref}
{...rest}
>
{typeof content === 'string' ? (
<Text type="body-sm-bold" color="inherit">
{content}
</Text>
) : (
content
)}
</Box>
);
});
Badge.displayName = 'Badge';
export default Badge;
index.ts (barrel export):
// packages/zest/src/Badge/index.ts import Badge from './Badge'; export default Badge;
MyComponent.test.tsx:
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { MyComponent } from './MyComponent';
import { ThemeProvider } from 'styled-components';
import { theme } from '@/libs/zest/theme';
const renderWithTheme = (component: React.ReactElement) => {
return render(
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
);
};
describe('MyComponent', () => {
it('renders children correctly', () => {
renderWithTheme(<MyComponent>Test Content</MyComponent>);
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
renderWithTheme(
<MyComponent onClick={handleClick} data-test-id="my-component">
Click me
</MyComponent>
);
const component = screen.getByTestId('my-component');
fireEvent.click(component);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick when disabled', () => {
const handleClick = jest.fn();
renderWithTheme(
<MyComponent onClick={handleClick} disabled data-test-id="my-component">
Click me
</MyComponent>
);
const component = screen.getByTestId('my-component');
fireEvent.click(component);
expect(handleClick).not.toHaveBeenCalled();
});
it('renders different variants correctly', () => {
const { rerender } = renderWithTheme(
<MyComponent variant="primary" data-test-id="my-component">
Primary
</MyComponent>
);
let component = screen.getByTestId('my-component');
expect(component).toHaveStyle({ backgroundColor: 'primary.600' });
rerender(
<ThemeProvider theme={theme}>
<MyComponent variant="secondary" data-test-id="my-component">
Secondary
</MyComponent>
</ThemeProvider>
);
component = screen.getByTestId('my-component');
expect(component).toHaveStyle({ backgroundColor: 'neutral.100' });
});
});
Design Tokens
Component Token Naming Convention
Component tokens follow this pattern: 'components.{component}.{property}.{variant}'
Examples:
// Colors 'components.badge.color.neutral.background' 'components.badge.color.foreground' 'components.button.color.brand.primary.background.default' 'components.tag.color.interactive.primary.foreground.default' // Border Radius 'components.badge.border-radius.default' 'components.button.border-radius.default' // Spacing 'components.button.spacing.lg.padding-y' 'components.inline-message.spacing.asset-gap'
Global Tokens
Use global tokens for values not specific to a component:
// In variants.ts 'global.white' 'global.gray.100' // In Box props <Box padding="global.md-1" gap="global.sm-2">
Token Access in variants.ts
import type { Variant, UseNewTokens } from '@/libs/zest-support';
const variants: Variant<UseNewTokens>[] = [
{
prop: 'variant',
variants: {
neutral: {
bg: 'components.badge.color.neutral.background', // Component token
color: 'components.badge.color.foreground', // Component token
},
},
},
{
prop: 'stroke',
variants: {
show: {
borderWidth: '2px',
borderColor: 'global.white', // Global token
},
},
},
];
Token Access in styles.ts
import type { BoxProps } from '../Box/BoxWithNewTokens';
export default {
display: 'inline-flex',
borderRadius: 'components.badge.border-radius.default',
padding: 'components.badge.spacing.padding',
} as BoxProps;
Responsive Design
Responsive Array Syntax
Use arrays for responsive values: [mobile, tablet, desktop]
<Box
width={['100%', '50%', '33.33%']}
flexDirection={['column', 'row']}
padding={['sm-1', 'md-1', 'lg-1']}
display={['none', 'flex']}
>
Conditional Responsive Patterns
<Box
display={[
shouldHideOnMobile ? 'none' : 'flex',
'flex',
]}
>
Accessibility Requirements
Semantic HTML
Use semantic HTML elements with Box as prop:
// Button
<Box as="button" onClick={handleClick}>
// Link
<Box as="a" href="/path">
// Heading
<Box as="h2">
// Section
<Box as="section">
ARIA Attributes
Always provide ARIA attributes for interactive elements:
<Box
as="button"
onClick={handleClick}
role="button"
aria-label="Close dialog"
aria-disabled={disabled}
aria-expanded={isExpanded}
aria-pressed={isPressed}
>
Keyboard Navigation
Ensure keyboard navigation works:
<Box
as="button"
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
tabIndex={0}
>
Focus Management
Provide visible focus indicators:
<Box
as="button"
onClick={handleClick}
tabIndex={0}
__dangerouslySetCustomCSS={{
'&:focus': {
outline: '2px solid',
outlineColor: 'primary.600',
outlineOffset: '2px',
},
}}
>
Component Export Patterns
Simple Component Export
// packages/zest/src/Badge/index.ts import Badge from './Badge'; export default Badge;
Multi-Part Component Export (Object.assign pattern)
For components with sub-components like NumberStepper, Accordion, Tag:
// packages/zest/src/NumberStepper/index.ts
import NumberStepper from './Container';
import Value from './ValueContainer';
import DecrementButton from './Buttons/DecrementButton';
import IncrementButton from './Buttons/IncrementButton';
export default Object.assign(NumberStepper, {
Value,
DecrementButton,
IncrementButton,
});
// Usage:
<NumberStepper>
<NumberStepper.DecrementButton onClick={decrement} />
<NumberStepper.Value>{count}</NumberStepper.Value>
<NumberStepper.IncrementButton onClick={increment} />
</NumberStepper>
Variant-Based Component Export
For components with distinct visual variants like Button, Tag:
// packages/zest/src/Button/index.tsx
import PrimaryButton from './PrimaryButton';
import SecondaryButton from './SecondaryButton';
import TertiaryButton from './TertiaryButton';
const Button = {
Primary: PrimaryButton,
Secondary: SecondaryButton,
Tertiary: TertiaryButton,
};
export default Button;
// Usage:
<Button.Primary onClick={handleClick}>Submit</Button.Primary>
Update Main Index File
After creating a component, export it from packages/zest/src/index.ts:
// packages/zest/src/index.ts
export { default as Badge } from './Badge';
export { default as Button } from './Button';
export { default as NumberStepper } from './NumberStepper';
// Types
export type { BadgeProps } from './Badge/types';
export type { BaseButtonPropsWithoutVariant as ButtonProps } from './Button/types';
Testing Patterns
Basic Component Tests
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import { theme } from '@/libs/zest/theme';
const renderWithTheme = (component: React.ReactElement) => {
return render(
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
);
};
describe('MyComponent', () => {
it('renders correctly', () => {
renderWithTheme(<MyComponent>Test</MyComponent>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
});
Testing Interactions
it('handles click events', () => {
const handleClick = jest.fn();
renderWithTheme(
<MyComponent onClick={handleClick} data-test-id="my-component">
Click me
</MyComponent>
);
fireEvent.click(screen.getByTestId('my-component'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
Testing Variants
it('renders different variants', () => {
const { rerender } = renderWithTheme(
<MyComponent variant="primary" data-test-id="test">
Primary
</MyComponent>
);
expect(screen.getByTestId('test')).toHaveAttribute('variant', 'primary');
rerender(
<ThemeProvider theme={theme}>
<MyComponent variant="secondary" data-test-id="test">
Secondary
</MyComponent>
</ThemeProvider>
);
expect(screen.getByTestId('test')).toHaveAttribute('variant', 'secondary');
});
Testing Accessibility
it('has proper accessibility attributes', () => {
renderWithTheme(
<MyComponent onClick={handleClick} data-test-id="test">
Click me
</MyComponent>
);
const component = screen.getByTestId('test');
expect(component).toHaveAttribute('role', 'button');
expect(component).toHaveAttribute('tabIndex', '0');
});
Common Patterns
Compound Components
For components with variants, use compound component pattern:
// Button.tsx
const ButtonPrimary: React.FC<ButtonProps> = (props) => (
<BaseButton {...props} variant="primary" />
);
const ButtonSecondary: React.FC<ButtonProps> = (props) => (
<BaseButton {...props} variant="secondary" />
);
export const Button = {
Primary: ButtonPrimary,
Secondary: ButtonSecondary,
};
// Usage
<Button.Primary onClick={handleClick}>Submit</Button.Primary>
<Button.Secondary onClick={handleCancel}>Cancel</Button.Secondary>
Forwarding Refs
For components that need ref forwarding:
export const MyComponent = React.forwardRef<HTMLDivElement, MyComponentProps>(
({ children, ...props }, ref) => {
return (
<Box ref={ref} {...props}>
{children}
</Box>
);
}
);
MyComponent.displayName = 'MyComponent';
Polymorphic Components
For components that can render as different HTML elements:
export interface MyComponentProps<T extends React.ElementType = 'div'> {
as?: T;
children: React.ReactNode;
}
export const MyComponent = <T extends React.ElementType = 'div'>({
as,
children,
...props
}: MyComponentProps<T> & Omit<React.ComponentPropsWithoutRef<T>, keyof MyComponentProps<T>>) => {
return (
<Box as={as} {...props}>
{children}
</Box>
);
};
Common Mistakes
❌ Don't hardcode colors:
// Wrong <Box backgroundColor="#ffffff" color="#333333">
✅ Do use theme tokens:
// Correct <Box backgroundColor="neutral.100" color="neutral.800">
❌ Don't hardcode spacing:
// Wrong <Box padding="16px" margin="24px">
✅ Do use spacing tokens:
// Correct <Box padding="md-1" margin="lg-1">
❌ Don't skip accessibility:
// Wrong
<Box onClick={handleClick}>Click me</Box>
✅ Do add proper accessibility:
// Correct
<Box
as="button"
onClick={handleClick}
role="button"
aria-label="Close dialog"
tabIndex={0}
>
Click me
</Box>
❌ Don't forget tests:
// Wrong - no tests
✅ Do write comprehensive tests:
// Correct
describe('MyComponent', () => {
it('renders correctly', () => { /* ... */ });
it('handles interactions', () => { /* ... */ });
it('is accessible', () => { /* ... */ });
});
Quick Reference
Component file structure:
MyComponent/ ├── MyComponent.tsx ├── MyComponent.types.ts ├── MyComponent.styles.ts ├── MyComponent.test.tsx └── index.ts
Basic component template:
import React from 'react';
import { Box, Text } from '@/libs/zest';
import { MyComponentProps } from './MyComponent.types';
export const MyComponent: React.FC<MyComponentProps> = ({
children,
variant = 'default',
size = 'md',
onClick,
disabled = false,
'data-test-id': testId,
}) => {
return (
<Box
as={onClick ? 'button' : 'div'}
onClick={onClick}
disabled={disabled}
data-test-id={testId}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
>
<Text type="body-md-regular">{children}</Text>
</Box>
);
};
Export pattern:
// packages/zest/src/index.ts
export { MyComponent } from './components/MyComponent';
export type { MyComponentProps } from './components/MyComponent';