| name | add-new-component |
| description | End-to-end guide for adding a new component to the Apsara design system. Use when creating a new React component including source, styles, tests, documentation, and playground examples. Triggers on tasks involving adding, scaffolding, or creating new components in the component library. |
| metadata | {"author":"raystack","version":"1.0","internal":true} |
Add New Component to Apsara
Step-by-step ultrathink instructions for adding a new component to the Apsara design system. Each component requires changes across two packages: packages/raystack/ (source, styles, tests) and apps/www/ (docs, demos, playground).
Files to Create/Modify
packages/raystack/
├── index.tsx # Add export (alphabetical)
└── components/<name>/
├── index.tsx # Re-export only
├── <name>.tsx # Component + Object.assign
├── <name>.module.css # Styles
└── __tests__/<name>.test.tsx # Tests
apps/www/src/
├── content/docs/components/<name>/
│ ├── index.mdx # Docs page
│ ├── demo.ts # Code demos
│ └── props.ts # Prop interfaces
└── components/playground/
├── <name>-examples.tsx # Playground example
└── index.ts # Register export
Step 1: Create the Component Source
Create packages/raystack/components/<name>/.
For simple components, define everything in a single file. For complex components with multiple sub-components, split into separate files:
# Simple
<name>.tsx # All sub-components + Object.assign
# Complex
<name>.tsx # Object.assign composition (imports sub-components)
<name>-root.tsx # Root wrapper
<name>-trigger.tsx # Trigger sub-component
<name>-content.tsx # Content sub-component
Component File Template
'use client';
import { ComponentName as ComponentPrimitive } from '@base-ui/react';
import { cx } from 'class-variance-authority';
import styles from './<name>.module.css';
const ComponentRoot = ({
className,
...props
}: ComponentPrimitive.Root.Props) => (
<ComponentPrimitive.Root
className={cx(styles.root, className)}
{...props}
/>
);
ComponentRoot.displayName = 'Component';
Key rules:
'use client' directive on all interactive component files
- Use React 19 ref-as-prop — plain function components, not
forwardRef/ElementRef; ref flows through ...props or is destructured when it must target a non-default element
displayName set for React DevTools (e.g., 'Component.Trigger')
cx() from class-variance-authority to merge CSS module class with user's className
- Spread
...props last so consumers can override defaults
Object.assign Composition
Multi-file:
import { ComponentRoot } from './<name>-root';
import { ComponentTrigger } from './<name>-trigger';
import { ComponentContent } from './<name>-content';
export const Component = Object.assign(ComponentRoot, {
Trigger: ComponentTrigger,
Content: ComponentContent
});
Single-file:
const ComponentRoot = (props) => (...);
const ComponentTrigger = (props) => (...);
const ComponentPanel = (props) => (...);
export const Component = Object.assign(ComponentRoot, {
Trigger: ComponentTrigger,
Panel: ComponentPanel
});
Base UI Primitive Wrapping (when applicable)
When the component wraps a Base UI primitive from @base-ui/react, follow these additional conventions:
Naming: The Base UI Popup (or Panel) sub-component is always exported as Content in Apsara. The Content wrapper internally composes Portal, Positioner, and Popup (or Panel) so consumers only deal with a single sub-component.
Content Props Interface: Merge Positioner props with Popup/Panel props so positioning config (side, align, sideOffset, etc.) is passed directly on <Component.Content>. Separate them internally via rest spread:
export interface ComponentContentProps
extends Omit<
ComponentPrimitive.Positioner.Props,
'render' | 'className' | 'style'
>,
ComponentPrimitive.Popup.Props {
showArrow?: boolean;
}
Content Component Template:
const ComponentContent = ({
className,
children,
showArrow = false,
style,
render,
ref,
...positionerProps
}: ComponentContentProps) => (
<ComponentPrimitive.Portal>
<ComponentPrimitive.Positioner
sideOffset={showArrow ? 10 : 4}
collisionPadding={3}
className={styles.positioner}
{...positionerProps}
>
<ComponentPrimitive.Popup
ref={ref}
className={cx(styles.popup, className)}
style={style}
render={render}
>
{children}
{showArrow && (
<ComponentPrimitive.Arrow className={styles.arrow}>
{/* arrow SVG */}
</ComponentPrimitive.Arrow>
)}
</ComponentPrimitive.Popup>
</ComponentPrimitive.Positioner>
</ComponentPrimitive.Portal>
);
ComponentContent.displayName = 'Component.Content';
Key rules:
ref forwards to the Popup/Panel element (the visible content container)
className and style apply to Popup/Panel, NOT the Positioner
- Positioner gets its own CSS class from the module (e.g.,
styles.positioner)
- If the Base UI primitive uses
Panel instead of Popup, substitute accordingly but still export as Content
- Arrow is optional, controlled by
showArrow prop (default false). When showArrow is true, increase sideOffset to account for arrow size
Existing examples:
Popover.Content wraps Portal > Positioner > Popup — see components/popover/popover.tsx
Tooltip.Content wraps Portal > Positioner > Popup with arrow support — see components/tooltip/tooltip-content.tsx
PreviewCard.Content wraps Portal > Positioner > Popup with arrow support — see components/preview-card/preview-card.tsx
Step 2: Create the Index File
Simple re-export:
export { Component } from './<name>';
Step 3: Add CSS Module Styles
Create <name>.module.css.
- Kebab-case class names (e.g.,
.accordion-trigger, .panel)
- Use
--rs-* CSS variables for all design tokens (no hardcoded colors/spacing)
- Use Base UI data attributes for state-based styling
.trigger {
cursor: pointer;
outline: none;
background: var(--rs-color-background-base-primary);
font-size: var(--rs-font-size-regular);
}
.trigger:hover,
.trigger:focus-visible {
background-color: var(--rs-color-background-base-primary-hover);
}
.trigger:disabled {
pointer-events: none;
opacity: 0.5;
}
.trigger[data-panel-open] .icon {
transform: rotate(180deg);
}
.panel {
height: var(--collapsible-panel-height);
overflow: hidden;
transition: height 150ms ease-out;
}
.panel[data-starting-style],
.panel[data-ending-style] {
height: 0;
}
Common --rs-* tokens:
- Colors:
--rs-color-foreground-base-primary, --rs-color-background-base-primary, --rs-color-border-base-primary
- Spacing:
--rs-space-2 through --rs-space-5
- Typography:
--rs-font-size-small, --rs-font-size-regular, --rs-line-height-regular
- Effects:
--rs-radius-2, --rs-shadow-lifted, --rs-shadow-inset
Step 4: Register the Export
Add to packages/raystack/index.tsx in alphabetical order:
export { Chip } from './components/chip';
export { CodeBlock } from './components/code-block';
export { Collapsible } from './components/collapsible';
export { Combobox } from './components/combobox';
Step 5: Write Tests
Create __tests__/<name>.test.tsx.
Test File Structure
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Component } from '../<name>';
import styles from '../<name>.module.css';
What to Test
- Basic rendering — renders children, applies custom
className, forwards ref
- Interaction — click handlers, open/close toggling, state changes
- Controlled vs uncontrolled —
value/open props, onChange callbacks
- Keyboard navigation — Tab, Enter, Space, Arrow keys as applicable
- Disabled state —
aria-disabled, no toggle on click
- Sub-components — className, ref forwarding for each sub-component
Testing Tips
Running Tests
pnpm --filter @raystack/apsara test -- --reporter=verbose components/<name>
Step 6: Add Documentation
Create apps/www/src/content/docs/components/<name>/ with three files.
The sidebar auto-discovers component pages from this directory structure (no config registration needed).
index.mdx
---
title: ComponentName
description: Short description of the component.
source: packages/raystack/components/<name>
tag: new
---
import { preview, controlledDemo, disabledDemo } from "./demo.ts";
<Demo data={preview} />
## Anatomy
Import and assemble the component:
\`\`\`tsx
import { Component } from '@raystack/apsara'
<Component>
<Component.Trigger />
<Component.Panel />
</Component>
\`\`\`
## API Reference
### Root
Groups all parts of the component.
<auto-type-table path="./props.ts" name="ComponentProps" />
### Trigger
Toggles the visibility of the panel.
<auto-type-table path="./props.ts" name="ComponentTriggerProps" />
### Panel
Contains the component content.
<auto-type-table path="./props.ts" name="ComponentPanelProps" />
## Examples
### Controlled
Description of the controlled example.
<Demo data={controlledDemo} />
### Disabled
Description of the disabled example.
<Demo data={disabledDemo} />
## Accessibility
- Bullet points about ARIA attributes, keyboard support, and WAI-ARIA patterns.
Frontmatter fields:
title — Component display name
description — Short summary
source — Path to component source (relative to repo root)
tag: new — Shows a "new" badge in the sidebar
demo.ts
Preview/Code demo (static code rendered as a live example):
'use client';
export const preview = {
type: 'code',
code: `<Component>
<Component.Trigger>Click me</Component.Trigger>
<Component.Panel>Content here</Component.Panel>
</Component>`
};
Tabbed code demo (multiple variants):
export const variantDemo = {
type: 'code',
tabs: [
{ name: 'Default', code: `<Component>...</Component>` },
{ name: 'Disabled', code: `<Component disabled>...</Component>` }
]
};
Playground demo (interactive with controls):
import { getPropsString } from '@/lib/utils';
export const playground = {
type: 'playground',
controls: {
disabled: { type: 'checkbox', defaultValue: false },
size: { type: 'select', options: ['small', 'medium', 'large'], defaultValue: 'medium' }
},
getCode: (props: Record<string, unknown>) => {
return `<Component${getPropsString(props)}>...</Component>`;
}
};
Use preview (code type) for simple components. Use playground for components with many configurable props.
props.ts
TypeScript interfaces with JSDoc comments consumed by <auto-type-table> in the MDX:
export interface ComponentProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
disabled?: boolean;
className?: string;
}
- Use
@defaultValue JSDoc tag to document defaults
- Keep descriptions concise
- Include
className prop on all sub-component interfaces
Step 7: Add Playground Example
Create apps/www/src/components/playground/<name>-examples.tsx:
'use client';
import { Component, Flex, Text } from '@raystack/apsara';
import PlaygroundLayout from './playground-layout';
export function ComponentExamples() {
return (
<PlaygroundLayout title='Component'>
<Flex direction='column' gap='large'>
<Text>Default:</Text>
<Component>
<Component.Trigger>Toggle</Component.Trigger>
<Component.Panel>Content</Component.Panel>
</Component>
</Flex>
</PlaygroundLayout>
);
}
Register in apps/www/src/components/playground/index.ts (alphabetical order):
export * from './code-block-examples';
export * from './<name>-examples';
export * from './combobox-examples';
Step 8: Verify
pnpm --filter @raystack/apsara build
pnpm --filter @raystack/apsara test -- --reporter=verbose components/<name>
pnpm --filter www build
Checklist: