Back to Ghost

Contributing to Shade

apps/shade/src/docs/contributing.mdx

6.37.19.2 KB
Original Source

import { Meta } from '@storybook/addon-docs/blocks';

<Meta title="Contributing" /> <div className="sb-doc">

Contributing to Shade

<p className="excerpt">This is the practical guide for adding to or changing the design system: where files go, how to write stories, what stops you from accidentally breaking everything downstream.</p>

Before you write any code

The first decision is always which layer your change belongs in. The Architecture page has the full breakdown, but the quick rule:

  • Visual values (colors, sizes, durations) → Tokens
  • Layout structure → Primitives
  • Generic UI controls → Components
  • Shared visual rules used by several components → Recipes (alongside components, surfaced under Foundations)
  • Product-shaped compositions → Features

If you're not sure between Components and Features, the smell test is: does the thing's name sound like Ghost or like the open web? KpiCardHeader is Ghost-y, Button is web-y.

The second decision is whether the change belongs in Shade at all. The default is to keep code local to your app first. Build it where you need it, ship it, and only consider promoting it to Shade once you find yourself building the same thing again somewhere else. Premature design system additions are expensive — once an abstraction has consumers, changing it gets harder fast.

Repo layout

apps/shade/
├── .storybook/                Storybook config
└── src/
    ├── components/
    │   ├── ui/                Generic controls + recipes
    │   ├── layout/            Page shells (Page, ListHeader, etc.)
    │   └── features/          Product compositions
    └── docs/                  These MDX pages

Entrypoint barrels live one level up: components.ts, primitives.ts, patterns.ts, etc. They re-export from the folders above.

Importing from Shade

Use a layer-specific subpath, not the root barrel:

ts
// Good
import {Button} from '@tryghost/shade/components';
import {Stack} from '@tryghost/shade/primitives';
import {createFilter} from '@tryghost/shade/patterns';
import {cn} from '@tryghost/shade/utils';
import {ShadeApp} from '@tryghost/shade/app';

// Avoid (compatibility lane only)
import {Button, Stack} from '@tryghost/shade';

The full migration story is on the Root Imports Migration page.

Inside Shade itself, use the @/ path alias for cross-file imports. Don't worry about the alias leaking into emitted .d.ts files — tsc-alias rewrites them to relative paths at build time so consumer apps resolve them correctly.

File names and Storybook titles

File names are kebab-case (dropdown-menu.tsx), matching what the ShadCN CLI generates. Components are PascalCase (DropdownMenu), hooks and functions are camelCase.

Each component file ships alongside its story:

button.tsx              The component
button.stories.tsx      Stories (CSF format)
button.mdx              Optional component-specific docs

Storybook titles follow the layer convention so the sidebar groups make sense:

LayerTitle prefix
Recipe / visual ruleFoundations / X
Layout primitivePrimitives / X
Generic controlComponents / X
Page shellLayout / X
Product compositionFeatures / X

Writing stories

Every documented component needs a .stories.tsx file with:

  • A title under the right sidebar group.
  • tags: ['autodocs'] so Storybook generates the docs page.
  • An overview via parameters.docs.description.component.
  • A handful of focused stories that cover the key variants and states, each with a one-line parameters.docs.description.story explaining when to use that variant.

A typical Button story:

tsx
import type {Meta, StoryObj} from '@storybook/react-vite';
import {Button} from './button';

const meta: Meta<typeof Button> = {
    title: 'Components / Button',
    component: Button,
    tags: ['autodocs'],
    parameters: {
        docs: {
            description: {
                component: 'Reusable button for actions. Pick a variant and size based on context.'
            }
        }
    }
};
export default meta;

type Story = StoryObj<typeof Button>;

export const Default: Story = {
    args: {children: 'Continue'},
    parameters: {docs: {description: {story: 'Primary call to action with default styling.'}}}
};

export const Destructive: Story = {
    args: {variant: 'destructive', children: 'Delete'},
    parameters: {docs: {description: {story: 'Use for dangerous or irreversible actions.'}}}
};

The general pattern: prefer many small focused stories with a one-line "when to use" description over long prose. Storybook is a gallery, not a textbook.

Implementing a component

Use ShadCN as a starting point

When the thing you're building maps onto a ShadCN primitive (Button, Dialog, Select, etc.), install it via the CLI:

bash
npx shadcn@latest add button

A few guardrails when running the CLI:

  • Never overwrite existing Shade components. When the prompt asks, choose "No" — we may have customizations the CLI doesn't know about.
  • Run on a fresh branch. It's much easier to review what the CLI did when the diff is clean.
  • If the component already exists, generate into a scratch repo and manually port the parts you actually want.
  • After integrating, run pnpm lint, pnpm test, and verify in Storybook.

Pattern for variants

Use cva() for variants and cn() for class merging. They're not optional — they're the reason classes don't conflict in unexpected ways.

tsx
import {cn} from '@/lib/utils';
import {cva, type VariantProps} from 'class-variance-authority';

const buttonVariants = cva(
    'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors',
    {
        variants: {
            variant: {
                solid: 'bg-primary text-primary-foreground hover:bg-primary/90',
                outline: 'border border-input bg-background hover:bg-accent',
                ghost: 'hover:bg-accent hover:text-accent-foreground'
            },
            size: {
                sm: 'h-9 px-3',
                md: 'h-10 px-4',
                lg: 'h-11 px-8'
            }
        },
        defaultVariants: {
            variant: 'solid',
            size: 'md'
        }
    }
);

interface ButtonProps
    extends React.ButtonHTMLAttributes<HTMLButtonElement>,
        VariantProps<typeof buttonVariants> {}

export function Button({className, variant, size, ...props}: ButtonProps) {
    return (
        <button
            className={cn(buttonVariants({variant, size}), className)}
            {...props}
        />
    );
}

Three things to notice: the base styles go in the first cva argument, variants in the object, defaults in defaultVariants. The component always merges its computed className with the consumer's via cn() so consumers can extend without wrapping.

Compound subcomponents over heavy props

When a component has multiple meaningful regions (a title, a meta line, an actions row), prefer compound subcomponents over a forest of props. It keeps the API composable and the consumer code readable:

tsx
function Header(props) { /* ... */ }
function HeaderTitle(props) { /* ... */ }
function HeaderMeta(props) { /* ... */ }
function HeaderActions(props) { /* ... */ }

Header.Title = HeaderTitle;
Header.Meta = HeaderMeta;
Header.Actions = HeaderActions;

export {Header};

Used like:

tsx
<Header>
    <Header.Title>Members</Header.Title>
    <Header.Meta>42,102 total</Header.Meta>
    <Header.Actions>
        <Button>New member</Button>
    </Header.Actions>
</Header>

Each part stays small, accepts and forwards className, and gets demonstrated in stories ("With actions", "With meta").

Custom components without ShadCN

For things that don't have a ShadCN starting point, the structure is the same — just simpler:

tsx
import {cn} from '@/lib/utils';

interface CustomComponentProps {
    className?: string;
}

export function CustomComponent({className, ...props}: CustomComponentProps) {
    return (
        <div className={cn('base-styles', className)} {...props} />
    );
}

Acceptance checklist

Before you mark a component done:

  • It does one thing well, and the API reflects that one thing.
  • Multi-region components use compound subcomponents.
  • The .stories.tsx covers the key variants and states.
  • Each story has a short "when to use" description.
  • Accessibility is considered: labels, focus management, disabled state, keyboard support.
  • className is forwarded.

What to do when something feels wrong

A few situations should make you stop and reconsider before pushing through:

  • The shared component is starting to grow product-specific props. Extract a Feature wrapper instead.
  • The API is requiring three or four optional props to do anything useful. Split or simplify.
  • A primitive is sprouting business logic. Move that logic up.
  • You can't decide between components and features. The decision is probably real — ask before guessing.

Repo conventions

For broader conventions across the Ghost monorepo (build commands, test expectations, commit and PR style), see AGENTS.md in the repo root.

</div>