Use when designing or changing a component's public API, its composition, or its state model in libs/ui-react or libs/ui-rnative — layering (core vs internal vs primitives), BaseProps/Props splits, converting a component to compound / changing its composition with createSafeContext, controlled/uncontrolled state, prop-naming conventions, and cross-platform API parity. Load this before shaping a new component, changing its props, or refactoring its composition.
Scanned 9/12/2026
Install to Claude Code
npx -y skills add LedgerHQ/lumen --skill component-architecture --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Component Architecture?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ledgerhq-component-architecture)More formats (shields.io, HTML) on the badges page.
---
name: component-architecture
description: >-
Use when designing or changing a component's public API, its composition, or
its state model in libs/ui-react or libs/ui-rnative — layering (core vs internal
vs primitives), BaseProps/Props splits, converting a component to compound /
changing its composition with createSafeContext, controlled/uncontrolled state,
prop-naming conventions, and cross-platform API parity. Load this before shaping
a new component, changing its props, or refactoring its composition.
paths: libs/ui-react/src/lib/Components/**/*.tsx, libs/ui-rnative/src/lib/Components/**/*.tsx, libs/ui-react/src/lib/Components/**/types.ts, libs/ui-rnative/src/lib/Components/**/types.ts
---
# Component architecture & API design
How Lumen components are shaped, layered and composed. This is the authoring
counterpart to `component-anatomy` (which covers *where files live*) — this skill
covers *what the component's API and internals look like*. The styling mechanics
live in `component-styling`.
## Layering: `core/` vs `internal/` vs `primitives/`
Components sit in one of a few layers; put new code in the right one.
- **`core/`** — the public components that ship from the package root
(`Button`, `Card`, `Switch`, …).
- **`internal/`** — shared building blocks that are **not** public API. Reach for
a `Base*` wrapper when several public components share the same chrome, variants
or behaviour: `BaseButton` backs `Button`, `IconButton`, `CardButton`,
`MediaButton`, `TileButton`; `BaseInput` backs `TextInput`, `SearchInput`,
`AddressInput`; `BaseTag` backs the tag family. Add one only when a second
consumer appears — don't pre-abstract.
- **`primitives/`** (React Native only) — token-constrained styled wrappers
(`Box`, `Text`, `Pressable`) that core RN components build on. Web has no
primitive layer; it composes semantic HTML + Tailwind + Radix/Base UI `Slot`.
- **`symbols/`** — the icon registry and generated icon components.
## BaseProps vs Props
When a `Base*` wrapper exists, split the types: the `Base*Props` carries the
shared surface, and each public component narrows it.
```ts
// internal/BaseButton/types.ts — the shared surface
export type BaseButtonProps = {
appearance?: 'base' | 'gray' | 'accent' | 'transparent' | 'no-background' | 'red';
size?: 'xs' | 'sm' | 'md' | 'lg';
isFull?: boolean;
loading?: boolean;
asChild?: boolean;
icon?: ComponentType<{ size?: IconSize; className?: string }>;
} & ComponentPropsWithRef<'button'>;
// core/Button/types.ts — narrows and constrains it
export type ButtonProps = {
/** @required */
children: ReactNode;
/** @default md */
size?: 'sm' | 'md' | 'lg'; // Button drops the 'xs' size
} & Omit<BaseButtonProps, 'children' | 'size'>;
```
Both types live in the component's `types.ts` (see `component-anatomy`). Prefer
`Omit`/`Pick` over redefining shared props.
## Compound components + `createSafeContext`
A component with distinct parts (`Card` + `CardHeader` + `CardContent` + `CardFooter`,
`Dialog` + `DialogTrigger` + `DialogContent`, `ListItem`, `BottomSheet`) exports
each sub-part from its folder barrel and shares state through a context built with
`createSafeContext` from `@ledgerhq/lumen-utils-shared` — **not** a hand-rolled
`createContext`.
```ts
// createSafeContext<CtxValue>(rootComponentName, defaultContext?)
// → [Provider, useSafeContext]
const [CardProvider, useCardContext] = createSafeContext<CardContextValue>('Card');
// In a sub-part: throws `${consumerName} must be used within Card` when the
// provider is missing and the context is required.
const ctx = useCardContext({ consumerName: 'CardHeader', contextRequired: true });
```
- Set `contextRequired: false` for a sub-part that may render standalone (returns
a partial/default context instead of throwing).
- If the component owns a `disabled` state, provide it through the shared
disabled context rather than a second provider — see `disabled-context`. `Card`
is the reference (`CardProvider` + `DisabledProvider`).
## Controlled / uncontrolled state
For any stateful, user-driven value (selection, open/checked, text), support both
controlled and uncontrolled use through `useControllableState` rather than
hand-rolling the fallback:
```ts
const [value, setValue] = useControllableState({
prop: valueProp, // controlled value (undefined ⇒ uncontrolled)
defaultProp, // initial value when uncontrolled
onChange, // fired on change (prop-wins by default)
});
```
- Expose the trio on the public API: `value` + `defaultValue` + `onChange`
(named per the component's domain, e.g. `checked`/`defaultChecked`/`onCheckedChange`).
- **Known debt**: `useControllableState` currently lives per-lib
(`libs/ui-react/src/utils/…`, `libs/ui-rnative/src/lib/utils/…`) rather than in
`utils-shared`. Use the one in your lib; don't add a third copy.
## API design rules
- **Prop names follow established vocabulary**: `onOpenChange` not `onToggle`,
`appearance` not `variant`. Match the name an existing component already uses
for the same concept.
- **Booleans are positive**: `isFull`, `overlay`, `loading` — never `noOverlay`.
- **Keep the public surface minimal and self-explanatory.** Every public prop
carries JSDoc with intent and an `@default` (or `@required`) tag.
- **JSDoc describes behaviour, not the type.** Do not enumerate union/literal
values already on the TypeScript type (`'auto'` / `'none'`, `'plain'`, …).
That makes JSDoc a second API that can drift. Keep the behavioural
description; let the type (and JSDoc parsers) surface the literals.
- **Shared logic goes in `utils-shared`**, not copied across components.
- **A hook that manages state *and* derives data *and* subscribes to events is
doing too much** — split it.
- A wrapper that spreads `ComponentPropsWithRef<'el'>` must actually forward those
props and `ref` (and `testID`/`...props` on RN) — don't silently drop `onClick`.
## Cross-platform API parity
The same component ships on web and native and should keep **matching prop names,
defaults and variant vocabulary** — an app author moving between platforms should
not relearn the API. `Button` is the reference: `appearance` / `size` / `isFull`
/ `loading` / `icon` are identical on both.
Divergence is allowed **only when a platform idiom demands it**, and it should be
deliberate. The tolerated example is `Switch`: web uses
`selected` / `defaultSelected` / `onChange`; native uses
`checked` / `defaultChecked` / `onCheckedChange` (aligning with the native
primitive). When you change a component on one platform, check the other's
`types.ts` and keep them in step unless there's such a reason.
## Memoization
Lumen does **not** blanket-memoize. `React.memo` is reserved for components with a
measured re-render cost (essentially only `AmountDisplay`); `useMemo`/`useCallback`
are used where there's real work (layout math, throttled scroll, expensive derived
styles), not by default. Add memoization when you can point to the reason, not
reflexively.
## Reference components to imitate
- **`Button` / `BaseButton`** — `internal/` layering, `Base*Props` → public props,
variant vocabulary.
- **`Card`** — compound parts + `createSafeContext` + disabled context.
- **`Select`** (web) — controlled/uncontrolled + compound architecture.
- **`Switch`** — controlled state and the tolerated cross-platform divergence.
## Review checks
Rules verifiable from a diff.
| Check | Applies to | Detect | Skip |
| --- | --- | --- | --- |
| Prop name off the established vocabulary | both | `onToggle`/`variant`-style names for existing concepts | genuinely new concept |
| Negative boolean prop | both | `no*`/`disable*`-phrased boolean props | — |
| Shared component family not split into `Base*Props` → public props | both | duplicated prop surface across sibling components | single-use component |
| Compound sub-part uses hand-rolled `createContext` instead of `createSafeContext` | both | `createContext(` in a component folder | genuinely global/app context |
| Controlled component hand-rolls the controlled/uncontrolled fallback | both | `value ?? internalState` logic instead of `useControllableState` | Radix/Base UI-delegated state (web) |
| Cross-platform prop divergence without a stated reason | both | prop names/defaults differ from the other platform's `types.ts` | documented platform-idiom divergence (e.g. `Switch`) |
| Reflexive memoization with no measured need | both | `memo`/`useMemo`/`useCallback` around trivial work | measured hot paths |
| Public prop missing JSDoc intent / `@default` | both | undocumented prop in `types.ts` | internal `Base*Props` |
| JSDoc enumerates union/literal values already on the type | both | JSDoc lists `'foo' \\| 'bar'` or bullet-lists the same literals as the type | restating a default with `@default` |
| Logic duplicated across components that belongs in `utils-shared` | both | copy-pasted helper in two component folders | — |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!