name: create-component description: Guide for creating a new UI component in @gridland/ui. Covers file structure, focus integration, keyboard handling, theme usage, JSDoc, export registration, and documentation.
Guide for creating a new UI component in @gridland/ui.
1. File structure
packages/ui/components/<component-name>/
<component-name>.tsx # Main component file
- Use kebab-case for directory/file names
- Use PascalCase for component function and props interface
2. Component template
// @ts-nocheck — only if using OpenTUI intrinsic elements (<box>, <text>, <span>)
import type { ReactNode } from "react"
import { textStyle } from "@/registry/gridland/lib/text-style"
import { useTheme } from "@/registry/gridland/lib/theme"
export interface MyComponentProps {
/** JSDoc every prop — agents and docs generators read these. */
children: ReactNode
}
/** One-line description of what the component does. */
export function MyComponent({ children }: MyComponentProps) {
const theme = useTheme()
// ...
}
// @ts-nocheckat top ONLY when using<box>,<text>,<span>- Import theme from
"@/registry/gridland/lib/theme", usetextStyle()for styling - Use the
@/registry/gridland/{ui,lib,hooks}/*alias form for every intra-package import; shadcn's CLI rewrites these to the user'scomponents.jsonaliases at install time - Never hardcode hex colors — use
theme.*tokens or accept color prop - JSDoc on props interface, every prop, and component function
3. Focus integration (interactive components only)
import { useInteractive, FocusScope } from "@gridland/utils"
const { isFocused, isSelected, focusId, focusRef, onKey } = useInteractive({
id,
disabled,
shortcuts: [{ key: "enter", label: "select" }],
})
// Attach focusRef to root element
<box ref={focusRef}>...</box>
// Wrap nested interactive content
<FocusScope trap selectable autoFocus>{children}</FocusScope>
// Handle keys while selected (fires only when isSelected)
onKey((e) => {
if (e.name === "return") submit()
})
disabledalone excludes from navigation — do NOT also settabIndex: -1useInteractiveinsideFocusScopeauto-binds to that scope (passscopeId={null}for global scope)- Display-only wrappers that share a
focusIdwith an inner interactive child should calluseInteractive({ id })withoutshortcutsand withoutonKey— the internal dispatch is a no-op so the inner component's shortcuts survive
4. Keyboard handling
import { useKeyboardContext } from "../provider/provider"
export interface MyComponentProps {
useKeyboard?: (handler: (event: any) => void) => void
}
const useKeyboard = useKeyboardContext(useKeyboardProp)
useKeyboard?.((event) => { /* handle keys */ })
5. Export registration
In packages/ui/components/index.ts:
export { MyComponent } from "./<component-name>/<component-name>"
export type { MyComponentProps } from "./<component-name>/<component-name>"
Both runtime export AND type export required.
6. Documentation
- Create the core demo in
packages/demo/demos/<component-name>.tsx— export a named app component (e.g.,export function MyComponentApp()) - Register it in
packages/demo/demos/index.tsx(import, re-export, and add todemosarray) - Create a doc page at
packages/docs/content/docs/components/<component-name>.mdx - If the MDX page needs multiple demo variants, create a thin wrapper at
packages/docs/components/demos/<component-name>-demo.tsxthat imports from@demos/<component-name>
Interactive components need interactive demos with useState + useKeyboard.
7. After creation
- Run
/reviewto validate focus coverage and export conventions - Run
/sync-contextto updatepackages/ui/CLAUDE.mdwith the new component