Composition

Compound components, the context hooks, and the rules for composing your own parts.

Most components in the library are a root plus dot-notation parts. The root shares its axes — variant, size, isDisabled — through a context, so a part never takes them as props.

<Button variant="danger" size="lg">
  <Icon icon={IconTrash} />
  <Button.Label>Delete</Button.Label>
</Button>

Button.Label names no variant and no size. It reads both.

The useX() hooks

Every compound root exports a hook so a custom child can match the parent without prop drilling.

import { Button, useButton } from "@delacour/native-ui/button";

function ButtonBadge({ count }: { count: number }) {
  const { variant, size, isDisabled } = useButton();
  return <Badge color={variant === "danger" ? "danger" : "default"}>{count}</Badge>;
}

<Button variant="danger">
  <Button.Label>Delete</Button.Label>
  <ButtonBadge count={3} />
</Button>;

Icons and text inherit

A root that can publish one treatment covering its whole subtree does. Button and Badge wrap their children in both an IconDefaultsProvider and a TextClassProvider, so a bare <Icon> or <Text> inside one comes out matching with nothing said at the call site.

A root whose parts carry two treatments does not publish — one provider cannot serve both. A ListGroup row has a title and a description; a navbar has a title and a subtitle; Screen.Error has a title and a message. Those keep per-part classes.

String children are wrapped

React Native crashes on bare text outside a <Text>, so every component that accepts free-form children wraps a string child in its own label part.

<Button>Save</Button>
// is
<Button><Button.Label>Save</Button.Label></Button>

Consecutive strings collapse into one label. Row {i} is a single piece of text — wrapping each part separately would space them apart by the component's own gap.

State cascades through context, not through selectors

Uniwind has no group-*, no peer-* and no :has(). Its compiler reads data-* off a single flat selector, and its runtime matches them against props on the component carrying the class — so no class on a parent can reach a child.

That is why <Field isInvalid> reddens the Input inside it through a React context rather than through a parent-scoped selector. Do not go looking for a selector-based path; there isn't one.

Precedence, and why it differs between components

Two ladders exist in the library and they are deliberately different.

Input.Group puts itself first: group → own prop → field. It owns the one box a grouped field renders into, and two answers to one question is not a state worth expressing.

Checkbox.Group puts the child first: own → group → field → default. It owns no box. It is a state controller that also carries shared defaults, which makes it the same kind of thing as Field — a wrapper a control overrides. "Make the group lg" and "make this one danger" are different questions and both get an answer.

Both are ?? chains and never ||, so an explicit isDisabled={false} opts a child out of a disabled group.

Writing your own part

Read the shared state from the component's context hook, and merge the caller's className through cn().

import { cn } from "@delacour/native-ui/lib/cn";
import { useButton } from "@delacour/native-ui/button";
import { Text } from "@delacour/native-ui/text";

export function ButtonHint({ className, ...props }: TextProps) {
  const { size } = useButton();
  return <Text.Caption className={cn(size === "lg" && "text-base", className)} {...props} />;
}
ButtonHint.displayName = "App.ButtonHint";

On this page