Welcome to the Stack-and-Flow Design System codebase. We strictly adhere to Atomic Design combined with the Container/Presentational pattern. Following these guidelines is MANDATORY for any PR.
Our UI is broken down into three main levels of complexity:
- Atoms: The basic building blocks (e.g.,
Button,Badge,Input). They do not depend on other components in the system, except utility functions. - Molecules: Groups of atoms bonded together to form a functional unit (e.g.,
Modaltypically uses buttons, typography, etc.). - Organisms: Complex UI components forming distinct sections of an interface (e.g., a
Headercomprising a logo, search molecule, and navigation atoms).
Every single component MUST be split into logic (Container) and rendering (Presentational). We achieve this through custom hooks and .tsx files.
Every component MUST live inside a kebab-case directory (src/components/atoms/button/) and contain EXACTLY these six files:
| File | Purpose | Rule |
|---|---|---|
types.ts |
Types & Variants | Defines component props using type, Storybook JSDoc controls, and cva variants. |
useButton.ts |
Container Hook | Contains ALL logic, state, refs, handlers, and cva class generation. |
Button.tsx |
Presentational Component | ONLY JSX. Consumes the hook. No state or cva calls. |
Button.test.tsx |
Tests | Covers hook logic and observable component behavior. |
Button.stories.tsx |
Documentation | Contains Storybook autodocs, args, and JSDoc blocks above const meta and story exports. |
index.ts |
Public API | Re-exports the named component and type exports. |
ALL cva variants go here, never in the hook or component.
Use JSDoc comments to automatically generate Storybook controls.
import { cva, type VariantProps } from 'class-variance-authority';
export const buttonVariants = cva(
['flex items-center justify-center font-secondary-bold'],
{
variants: {
variant: {
primary: 'bg-secondary text-text-dark',
ghost: 'bg-transparent text-text-light'
}
},
defaultVariants: {
variant: 'primary'
}
}
);
export type ButtonProps = {
/**
* @control select
* @default primary
*/
variant?: VariantProps<typeof buttonVariants>['variant'];
className?: string;
disabled?: boolean;
};The hook returns everything the element needs: CSS classes, event handlers, mapped props, and aria attributes.
import { buttonVariants, type ButtonProps } from './types';
export const useButton = ({
variant = 'primary',
className,
disabled = false,
...props
}: ButtonProps) => {
// Logic here (refs, state, effects)
const buttonClass = buttonVariants({ variant, className });
return {
buttonClass,
disabled,
...props
};
};It only destructures what it needs from the hook and renders it.
import type { FC, ComponentProps } from 'react';
import type { ButtonProps } from './types';
import { useButton } from './useButton';
const Button: FC<ButtonProps & ComponentProps<'button'>> = ({ ...props }) => {
const { buttonClass, disabled, children } = useButton(props);
return (
<button className={buttonClass} disabled={disabled} {...props}>
{children}
</button>
);
};
export { Button };export { Button } from './Button';
export type * from './types';typeoverinterface: ALWAYS useexport type ComponentProps = {}. Do NOT useinterface.- No
any: Explicitanyis strictly prohibited. If you don't know the type, useunknownor narrow it down properly. - Explicit Props: Never implicitly type props. Everything MUST be explicitly defined in
types.ts. - Component definition: Use
FC<ComponentProps>and named component exports — never default component exports.
- English only: All stories must be written in English.
- Mandatory Controls: Use JSDoc comments (
/** @control text */) intypes.tsto power the controls. - Mandatory Description: Every component story MUST include a JSDoc block immediately above
const metawith## Descriptionrequired. Use## Dependenciesand## Usage Guideonly when applicable. - No
parameters.docs.description.component: component docs live in JSDoc aboveconst meta, not inparameters.docs.description.component. - Story-level docs: add useful JSDoc immediately above every
export const StoryName; it must explain the scenario and why it matters, not just restate the story name. - No redundant stories: each story must demonstrate a distinct state, variant axis, composition constraint, accessibility behavior, or integration context.
- No generic
DarkModestory: use the Storybook dark-mode toolbar for normal theme coverage; dedicated dark-mode stories are only for local scope, portal inheritance, or theme-specific regressions the toolbar cannot express. - Args: Define default
argsfor the base story without overridingdefaultVariants.
We use Tailwind v4 with @theme configurations defined in src/styles/theme.css.
- MANDATORY: You MUST use the design system's CSS custom properties (tokens) via Tailwind classes.
- NO HARDCODING: Never hardcode colors (e.g.,
#FF0000) or arbitrary values in inline styles. Arbitrary Tailwind sizing/typography classes are allowed only insidetypes.tsCVA definitions for explicitly approved compact/dense variants; arbitrary colors remain forbidden. - Use the predefined classes:
text-text-dark,bg-secondary,gap-sm,fs-h1, etc.
Accessibility is a core feature, not an afterthought.
- ARIA Attributes: Interactive elements MUST have appropriate ARIA attributes (
aria-expanded,aria-pressed,aria-hidden, etc.). - Dynamic
aria-label: Do not hardcodearia-label. Expose it as a prop so consumers can customize it for translation/context. - Roles: Explicitly define
rolewhen semantics require it (e.g.,role="status"on Badge,role="switch"on toggleable buttons). - Keyboard Navigation: Ensure elements are focusable and visually outline focus (
focus-visible). Our global styles handle focus rings natively.
- Directories:
kebab-case(e.g.,src/components/atoms/date-picker/). - Components & Files:
PascalCase(e.g.,DatePicker.tsx,DatePicker.stories.tsx). - Hooks:
camelCasewith auseprefix (e.g.,useDatePicker.ts). - Types:
PascalCase(e.g.,DatePickerProps). - CVA Variants:
camelCasewith aVariantssuffix (e.g.,datePickerVariants).
Every component in the design system MUST have a corresponding test file that follows these conventions.
We follow the Container/Presentational split in testing:
| Layer | Tool | What to test |
|---|---|---|
useComponentName.ts (Hook) |
renderHook |
Pure logic: default values, computed props, event handlers, return shape |
ComponentName.tsx (Component) |
render + screen + userEvent |
Observable behavior: accessibility, disabled state, loading, click handling |
Never test implementation details: do NOT assert on CSS class strings, internal ref values, or which variant string is applied to the DOM. Test what a real user (or screen reader) would observe.
Place the test file alongside the component — not in a separate __tests__ directory:
src/components/atoms/button/
Button.tsx
useButton.ts
types.ts
index.ts
Button.stories.tsx
Button.test.tsx ← here
Every component test file MUST cover:
- Default state — the component renders without required props
disabledprop — the element is disabled whendisabled={true}isLoadingprop — the element is disabled whenisLoading={true}(even ifdisabled={false})onClickhandler — called when interactive and not loading; NOT called when loadingaria-label— accessible name is applied correctly- Hook defaults —
disabled: falseandisLoading: falseare returned by default
Mock only the packages that the component imports. lucide-react/dynamic.js and spinners-react can break jsdom through dynamic modules or CSS animations, but each mock should exist only when the component actually uses that package:
vi.mock('lucide-react/dynamic.js', () => ({
DynamicIcon: () => null
}));
// Only if the component imports spinners-react
vi.mock('spinners-react', () => ({
SpinnerCircular: () => null
}));When a component renders a loading spinner, it must use SpinnerCircular from spinners-react, following Button as the reference. Do not implement component-local CSS spinners.
Also mock any CSS files imported directly from the component:
vi.mock('@/components/utils/styles/index.css', () => ({}));Hook test — use renderHook:
import { renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useButton } from './useButton';
describe('useButton — logic', () => {
it('returns disabled: false by default', () => {
const { result } = renderHook(() => useButton({}));
expect(result.current.disabled).toBe(false);
});
it('returns the correct variant when variant: ghost is passed', () => {
const { result } = renderHook(() => useButton({ variant: 'ghost' }));
expect(result.current.variant).toBe('ghost');
});
});Component test — use render + screen + userEvent:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Button } from './Button';
describe('Button — component behavior', () => {
it('is disabled when isLoading is true', () => {
render(<Button text="Loading" isLoading disabled={false} />);
expect(screen.getByRole('button', { name: 'Loading' })).toBeDisabled();
});
it('does NOT call onClick when isLoading is true', async () => {
const handleClick = vi.fn();
render(<Button text="Saving" isLoading onClick={handleClick} />);
await userEvent.click(screen.getByRole('button', { name: 'Saving' }));
expect(handleClick).not.toHaveBeenCalled();
});
});pnpm run test # single run
pnpm run test:watch # watch mode
pnpm run test:coverage # with coverage reportThe following practices will result in PR rejection:
- NO combining Presentational and Container logic in the same
.tsxfile. - NO putting
cvainside the.tsxoruseHook.tsfile (it belongs intypes.ts). - NO
export interfacein TypeScript. - NO arbitrary Tailwind colors (
text-[#000]). Arbitrary sizing/typography values are allowed only in CVA (types.ts) for explicitly approved compact/dense variants. - NO Spanish code or documentation (variables, comments, stories must be in English).
- NO
anytypes. - NO skipped accessibility (
aria-*or missing keyboard focus). - NO multiple components exported from a single file. One component = one directory.