Composition patterns
How Shells wrap Core to extend functionality, plus a guide for building your own custom Shell.
reopt designUpdated
Shell → Core composition
A Shell imports Core, wraps it, and extends its functionality. We'll use ContentTabs as the example.
"use client";
import { type TabDef, TabsRoot, TabList, Tab, TabPanel } from "@reopt-ai/opt-ui";
export interface ContentTabsProps {
tabs: TabDef[];
defaultTabId?: string;
}
export function ContentTabs({ tabs, defaultTabId }: ContentTabsProps) {
return (
<TabsRoot defaultSelectedId={defaultTabId ?? tabs[0]?.id}>
<TabList aria-label="콘텐츠 탭">
{tabs.map((tab) => (
<Tab key={tab.id} id={tab.id}>{tab.label}</Tab>
))}
</TabList>
{tabs.map((tab) => (
<TabPanel key={tab.id} tabId={tab.id}>
{tab.content}
</TabPanel>
))}
</TabsRoot>
);
}Pattern summary: a Shell (1) defines its data interface, (2) imports Core, and (3) maps data onto Core. The Shell itself contains no styling code.
Data-driven rendering
A Shell accepts a data array via props and maps it onto Core internally. Thanks to this pattern, the consumer only has to prepare the data.
// 사용처 (서버 컴포넌트)
const tabs: TabDef[] = [
{ id: "overview", label: "개요", content: <Overview /> },
{ id: "api", label: "API", content: <ApiDocs /> },
];
// Shell에 데이터 전달 — 렌더링은 Shell이 담당
<ContentTabs tabs={tabs} />Provider nesting
FaqAccordion nests multiple DisclosureRoot instances inside a CompositeProvider. The Composite handles keyboard navigation while each DisclosureRoot manages its own open/close state.
"use client";
import {
CompositeZone,
CompositeRow,
CompositeItem,
DisclosureRoot,
DisclosureTrigger,
DisclosureContent,
type FaqItem,
} from "@reopt-ai/opt-ui";
export function FaqAccordion({ items }: { items: FaqItem[] }) {
return (
<CompositeProvider focusLoop orientation="vertical">
<Composite role="region" aria-label="자주 묻는 질문" className="...">
{items.map((faq) => (
<DisclosureRoot key={faq.id}>
<div>
<CompositeItem render={<DisclosureTrigger />}>
{faq.question}
</CompositeItem>
<DisclosureContent>
<p>{faq.answer}</p>
</DisclosureContent>
</div>
</DisclosureRoot>
))}
</Composite>
</CompositeProvider>
);
}Passing DisclosureTrigger to CompositeItem's render prop uses a render prop to compose a Composite item and a Disclosure trigger into a single element.
Building a custom Shell
You can build a new Shell in four steps.
Step 1. Pick the Core
Choose the Core that matches your UI pattern. For example, a notifications list needs Disclosure (open/close) and Composite (keyboard navigation).
Step 2. Define the data interface
// lib/types.ts에 추가
export interface NotificationItem {
id: string;
title: string;
body: string;
time: string;
}Step 3. Compose Core
// shells/notification-list.tsx
"use client";
import {
CompositeZone,
CompositeRow,
CompositeItem,
DisclosureRoot,
DisclosureTrigger,
DisclosureContent,
type NotificationItem,
} from "@reopt-ai/opt-ui";
export interface NotificationListProps {
items: NotificationItem[];
}
export function NotificationList({ items }: NotificationListProps) {
return (
<CompositeProvider focusLoop orientation="vertical">
<Composite role="region" aria-label="알림 목록" className="...">
{items.map((item) => (
<DisclosureRoot key={item.id}>
<div>
<CompositeItem render={<DisclosureTrigger />}>
<span>{item.title}</span>
<time>{item.time}</time>
</CompositeItem>
<DisclosureContent>
<p>{item.body}</p>
</DisclosureContent>
</div>
</DisclosureRoot>
))}
</Composite>
</CompositeProvider>
);
}Step 4. Add the export
Add a barrel export in shells/index.ts and you're done.
// shells/index.ts
export { NotificationList } from "./notification-list";