# Components — authoring & styling conventions

Auto-loaded when working in `packages/components/src/components/**`. Covers both the
styled component files (`index.tsx`) and their co-located CVA variant files (`styles.ts`).

---

## Component Authoring (`index.tsx`)

Styled components in `@cdx-ui/components` follow a consistent pattern. Read the full guides before making changes:

- `docs/internal/practices/composition.md` — compound component pattern, naming conventions
- `docs/internal/practices/types.md` — prop types, `I*Props` vs `*Props`, exporting
- `docs/internal/practices/styling.md` — `cn()`, CVA, token classes, `Platform.select()`
- `docs/internal/practices/state.md` — `useControllableState`, `composeEventHandlers`
- `docs/internal/practices/data-attributes.md` — `data-[state]`, `data-slot`, and `data-icon` conventions
- `docs/internal/practices/as-child.md` — styled-layer `asChild` via re-exported `Slot`

## Six standards (Standards 4–6 apply here)

Standards 1–3 govern primitive-layer authoring (see `packages/primitives/src/CLAUDE.md`).
Standards 4–6 govern the styled layer:

### Standard 4 — `data-slot` attributes

Every public styled slot emits a stable `data-slot="<component>-<part>"` attribute on its outermost rendered element. The convention is lowercase, kebab-case, `<component>` then `-<slot>` if not the root.

| Slot               | Attribute                    |
| ------------------ | ---------------------------- |
| `<Button>` root    | `data-slot="button"`         |
| `<Button.Label>`   | `data-slot="button-label"`   |
| `<Button.Icon>`    | `data-slot="button-icon"`    |
| `<Button.Spinner>` | `data-slot="button-spinner"` |
| `<Button.Group>`   | `data-slot="button-group"`   |
| `<IconButton>`     | `data-slot="icon-button"`    |
| `<Dialog.Content>` | `data-slot="dialog-content"` |

Use `dataAttributes({ slot: '<component>-<part>' })` from `@cdx-ui/primitives` so the helper handles native (flat `data-*` keys) and web (RNW `dataSet`) correctly.

#### Combining multiple data-attribute sources — use `mergeDataAttributes`

When the same element receives `data-*` from more than one source (e.g. a slot identifier plus a position hint, or consumer-supplied data attrs flowing in through props), do **not** chain multiple `{...dataAttributes(...)}` spreads — on web each call returns `{ dataSet: {...} }` and a plain JSX spread replaces the `dataSet` key entirely, so the second source wipes out the first. Use `mergeDataAttributes(...)` from `@cdx-ui/primitives` to combine sources; later args win on key conflicts:

```tsx
import { dataAttributes, mergeDataAttributes } from '@cdx-ui/primitives';

<Element
  ref={ref}
  className={...}
  style={style}
  {...props}
  {...mergeDataAttributes(
    props,                 // pulls in any consumer-supplied data attrs
    dataAttributes({ slot: '<component>-<part>' }),
    positionToDataIcon(position),                      // optional, may be undefined
  )}
/>
```

Spread `{...props}` first, then the merge — so any keys that `mergeDataAttributes` produces overwrite their counterparts in props with the merged values.

When the styled component renders a primitive Root, the primitive Root applies the same pattern internally — incoming data attrs (slot from the styled layer) are merged with the primitive's own interaction-state data attrs and emitted once. The styled layer doesn't need to do anything special; just pass `data-slot` through and the primitive's merge does the rest.

### Standard 5 — Static design variants vs. interaction state

Variant functions take only static design inputs (`variant`/`color`/`size`/`fullWidth`/etc.). Interaction states (`hover`/`focus`/`active`/`disabled`/`focus-visible`) are expressed as `data-[...]:` selectors **inside** CVA variant strings, never as variant inputs.

- CSS-layer state changes are free of React work: when the primitive flips `data-hover=true`, the CSS engine (web) and Uniwind's native style layer reconcile styles without re-rendering React, re-running CVA, or invoking `cn()`/`tailwind-merge`.
- Variant-input state changes cascade React work: CVA re-runs, `cn()` re-merges, and className-prop reconciliation cascades through the tree.
- Sub-slots can react to root state via parent selectors (`data-[hover=true]:`) without each slot subscribing to context (per Standard 1).
- **Native compound conditions** — on native, prefer single-condition `data-[field-state=...]` selectors backed by a primitive-derived compound-state attribute over compound `data-[]:data-[]` chains (Uniwind matches only the last condition). Web may keep compound chains. See `Input/styles.ts` and `Select/styles.ts`; full practice in `docs/internal/practices/data-attributes.md` § "Compound state on native".

### Standard 6 — Position hints (`data-icon="inline-start|inline-end"`)

Icon-/spinner-bearing styled slots accept an optional `position?: 'leading' | 'trailing'` prop that emits `data-icon="inline-start|inline-end"`. Spacing rules in CVA target the `data-icon` attribute via logical-property utilities (`me-*`/`ms-*`) so RTL is handled for free.

Mapping (via the shared `positionToDataIcon` helper at `packages/components/src/utils/positionToDataIcon.ts`):

- `position="leading"` → `data-icon="inline-start"`
- `position="trailing"` → `data-icon="inline-end"`
- `position` omitted → no `data-icon` attribute, baseline spacing applies

```tsx
import { positionToDataIcon, type IconSlotPosition } from '../../utils/positionToDataIcon';

interface ButtonIconProps {
  position?: IconSlotPosition;
  // ...
}

<Icon {...positionToDataIcon(position)} {...props} />;
```

```tsx
<Button>
  <Button.Icon as={CheckIcon} position="leading" />
  <Button.Label>Confirm</Button.Label>
</Button>
```

CVA expresses padding via logical-property selectors:

```ts
cva(['data-[icon=inline-start]:me-1.5 data-[icon=inline-end]:ms-1.5'], { ... });
```

Where: **styled layer.** Pure styling hint with no behavior or a11y semantics — fails Standard 3, so no primitive involvement.

## Canonical reference

`packages/components/src/components/Button/index.tsx` + `Button/styles.ts` is the canonical example for Standards 4–6:

- `data-slot` on `Button`, `Button.Label`, `Button.Icon`, `Button.Spinner`, `Button.Group`
- `asChild` re-uses `Slot` from `@cdx-ui/primitives` — no new primitive needed for sub-slots
- `buttonIconVariants` / `buttonSpinnerVariants` carry `data-[icon=inline-start]:me-1.5 data-[icon=inline-end]:ms-1.5` so leading/trailing padding works without per-call overrides

`packages/components/src/components/QuickActionButton/index.tsx` shows the second axis of Standard 1: a styled-layer sub-slot (the Indicator) opts into `useButtonContext()` when its styling reacts to interaction state. The pattern is the same as the primitive Root — call `useButtonContext()`, spread `mergeDataAttributes(props, dataAttributes({ ... }))` — only it lives in the styled layer.

### Pattern summary

Each component follows this structure (see `Button/index.tsx`):

1. **Imports** — RN base components, primitive `Root` and `Slot` from `@cdx-ui/primitives`, `cn` + style context from `@cdx-ui/utils`, variant functions from `./styles`
2. **Scope constant** — `const SCOPE = 'COMPONENT_NAME'` for `useStyleContext` with a `ParentContext.Provider` wrapper for static variant context
3. **Root component** — renders the primitive Root (or a host element directly when no primitive exists), spreads `dataAttributes({ slot: '<component>' })`
4. **Sub-slots** — each accepts `asChild`, renders a host element via `const Comp = asChild ? Slot : DefaultElement`, spreads `dataAttributes({ slot: '<component>-<slot>' })`. Sub-slots **do not** call `useButtonContext()` (or its equivalent) unless their own styling reacts to interaction state.
5. **Position-aware sub-slots** — icon/spinner-style slots accept `position?: 'leading' | 'trailing'` and emit `data-icon` via `dataAttributes({ icon: '...' })`.
6. **Compound export** — `Object.assign(RootComponent, { Sub1, Sub2 }) as CompoundType`

### Key rules

- Props interface extends RN base props + primitive `I*Props` + CVA `VariantProps` + `{ className?: string }`
- Always accept and spread `style` alongside `className`
- Variant defaults in destructuring must match CVA `defaultVariants`
- `displayName` set on every exported sub-component
- `className` is merged last via `cn()` so consumer overrides always win
- Never hardcode colors — use semantic token classes (`bg-surface-*`, `text-content-*`, `border-stroke-*`)
- Event handlers composed via `composeEventHandlers` from `@cdx-ui/utils`
- Third-party libraries fully abstracted — no adopted library types leak to consumers
- **`data-slot`** emitted on every styled slot via `dataAttributes({ slot: '<component>-<part>' })`
- **`asChild`** supported on every styled slot — `const Comp = asChild ? Slot : DefaultElement`
- **`position`** prop on icon/spinner-style slots maps to `data-icon="inline-start|inline-end"`

---

## Styling Conventions (`styles.ts`)

Variant definitions live in co-located `styles.ts` files using CVA. Read the full guides before making changes:

- `docs/internal/practices/styling.md` — `cn()`, CVA, token architecture, dark mode, `Platform.select()`
- `docs/internal/practices/data-attributes.md` — `data-[state]` and `data-icon` selectors

### Pattern summary

Each `styles.ts` exports CVA variant functions and their derived `VariantProps` types (see `Button/styles.ts` as the canonical example):

```ts
import { Platform } from 'react-native';
import { cva, type VariantProps } from 'class-variance-authority';
import { DISABLED_CURSOR, TRANSITION_COLORS } from '../../styles/constants';

export const componentRootVariants = cva(
  [
    /* base classes + interaction state selectors (data-[hover=true]:..., data-[active=true]:...) */
  ],
  {
    variants: {
      /* static design axes only — variant, color, size, fullWidth, etc. */
    },
    compoundVariants: [
      /* variant × variant combinations */
    ],
    defaultVariants: {
      /* defaults matching the component's destructured defaults */
    },
  },
);

export type ComponentVariantProps = VariantProps<typeof componentRootVariants>;
```

### Key rules

- **Semantic token classes only** — use `bg-surface-action-strong`, `text-content-primary`, `border-stroke-primary`, etc. Never hardcode hex colors, `bg-white`, `text-black`, or raw Tailwind color scales for themed values.
- **`data-[state]` selectors** — use `data-[disabled=true]:`, `data-[hover=true]:`, `data-[active=true]:`, `data-[focus-visible=true]:` for interaction states. Primitives emit these data attributes (Standard 1). **Interaction state is never a CVA variant input** (Standard 5). On native, prefer single-condition `data-[field-state=...]` selectors backed by a primitive-derived compound-state attribute over compound `data-[]:data-[]` chains (which only match the last condition); web may keep compound chains. See `Input/styles.ts` / `Select/styles.ts`.
- **`data-[icon=inline-start|inline-end]` selectors** for icon/spinner position spacing (Standard 6) — pair with logical-property utilities (`me-*`/`ms-*`) so RTL flips automatically.
- **`Platform.select()`** for platform-specific classes — web gets `data-[hover=true]` styles (hover exists on web); native responds only to `data-[active=true]` (press). Always set `default` (native) and `web` keys.
- **Shared style primitives** — import `DISABLED_CURSOR`, `TRANSITION_COLORS`, etc. from `../../styles/constants` for cross-component consistency.
- **`web:` prefix** for web-only utilities — e.g., `web:outline-none`, `web:focus-visible:ring-2`.
- **`compoundVariants`** for variant × variant combinations where simple variant merging is insufficient.
- **`defaultVariants`** must match the destructured defaults in the component's `index.tsx`.
- **One `cva()` per sub-component** — root, text, icon, spinner, etc. each get their own variant function.
- **Export `VariantProps` types** — derived via `VariantProps<typeof variantFn>` for the component layer to consume.
- **No dynamic string construction** — all Tailwind class names must be statically detectable by the Tailwind scanner.
- **CSS variables for token values** — use `rounded-[var(--border-radius-button)]` syntax when referencing design tokens not mapped to Tailwind utilities.
