Stars, Anchors, and Passthroughs: Six New Primitives in This Release
This release focuses on interactive form controls and layout primitives that round out the library's coverage — a compound star rating, a fully-featured searchable select, a lightweight Chakra-style popover, a themeable list system with an inline action menu, a passthrough portal helper, and a small separator utility. Here's how each one is built and how to use it.
Rating
A compound, form-aware rating control — Root, Label, Control, Item — for star ratings that support hover previews, half-star precision, and full accessibility semantics out of the box.
How it works internally:
State lives entirely in Root, which supports both controlled (value) and uncontrolled (defaultValue) usage through a plain isControlled check rather than a separate hook, since the value here is a single number rather than anything that needs deep-equality handling. Two numbers are tracked side by side: a committed value and a transient hoverValue, and everything downstream reads a derived displayValue (hoverValue ?? value) — that's what lets hovering over the third star preview "3 stars filled" without touching the actual committed value until a press lands.
Each Item figures out its own fill state independently through a fillFraction calculation compared against displayValue and its own 1-based index: full if the display value has reached it, half if allowHalf is on and the value lands within half a star, otherwise empty. The half-star interaction itself is resolved from touch position — resolveValueFromLocation reads locationX off the press event and compares it against the item's own measured width (captured via onLayout into a ref, so no re-render is needed just to know how wide the star is) to decide whether the left or right half was tapped.
Rendering an actual star is a two-layer trick rather than a partial-fill SVG gradient: an outline-only Star is drawn at full size as the base layer, and a solid-color Star is drawn on top inside a View clipped (overflow: hidden) to exactly resolvedSize * fillFraction pixels wide — so a half-filled star is really a full star icon behind a clipped half-width copy of itself, not a special half-star glyph.
Color resolution follows a small precedence chain in resolvePaletteColor: an explicit colorPalette prop is looked up first against the rating theme config, then against the raw theme colors object, then falls through to a raw string if colorPalette itself happens to already be a valid color; omitting the prop entirely defaults to the theme's warning color, which is the conventional "star yellow" a rating control is expected to use without every consumer having to specify it. On web, Root also renders a hidden <input type="hidden"> when a name is supplied, purely so the control can participate in a real HTML form submission — a no-op on native, where there's no form to submit to.
<Rating.Root
count={5}
defaultValue={3.5}
allowHalf
onValueChange={({ value }) => submitReview(value)}
>
<Rating.Label>How was your stay?</Rating.Label>
<Rating.Control />
</Rating.Root>
Key props (Rating.Root):
| Prop | Type | Default | Description | |||
|---|---|---|---|---|---|---|
count | number | 5 | Number of items rendered | |||
value / defaultValue | number | — | Controlled / uncontrolled rating value | |||
allowHalf | boolean | false | Enables half-star precision | |||
colorPalette | string | "warning" | Theme key (or raw color) for filled items | |||
size | `"sm" \ | "md" \ | "lg" \ | number` | "md" | Icon size |
disabled / readOnly | boolean | false | Disables interaction or hover preview only | |||
name / form | string | — | Web-only hidden-input form association | |||
translations | { ratingValue } | English default | Accessibility label generator | |||
onValueChange / onHoverChange | functions | — | Commit and hover callbacks |
Rating.Item also accepts its own children for a fully custom icon, and Rating.Control renders count auto-generated items when no children are passed.
Select
A headless-feeling, fully controllable select built to behave like a real native picker on phones and a proper anchored dropdown on web, backed by the same createListCollection shape used elsewhere in the library.
How it works internally:
The data layer is deliberately decoupled from the UI: createListCollection wraps a plain items array with resolver functions (itemToValue, itemToString, itemToDisabled) and a groupBy, giving back a stable object with find, indexOf, and getGroups helpers. Grouping itself is handled by a standalone groupItems function rather than being computed once and cached on the collection, specifically so it can be re-run against a filtered item list during search — grouping that was baked in against the original unfiltered items would silently stop matching once a search query trimmed the list down.
Platform is where this component diverges most from a typical cross-platform abstraction: on web it renders an absolutely-positioned dropdown that measures the trigger, flips above the trigger when there's more room up than down, and can lock its width to the trigger's own width via sameWidth — the same measure-then-flip strategy used by the library's other anchored overlays. On native, the same Modal instead renders a bottom sheet that slides up from the screen's edge with a grabber handle and, when multiple is set, a "Done" button — because a floating dropdown anchored to a small on-screen trigger doesn't hold up as a touch interaction the way a full-width sheet does.
Search is debounced through useDebouncedInput, which returns both an immediate value (so the input feels responsive on every keystroke) and a debounced value used to actually filter or fire onSearch — that split avoids re-filtering a large collection on every character while typing quickly. When onSearch is supplied, Select stops filtering collection locally at all and treats the parent as the source of truth, which is what lets a select back onto a paginated API instead of an in-memory list.
Keyboard handling is web-aware but harmless elsewhere: onKeyDown/onKeyPress props are wired on the trigger and the search box respectively, but only actually attached when Platform.OS === "web", so the same component doesn't carry dead listeners on iOS/Android. OptionRow is individually memoized so that typing in the search box or moving the keyboard highlight only re-renders the rows whose selected/highlighted state actually changed, not the entire list.
const countries = createListCollection({
items: [
{ value: "np", label: "Nepal", group: "Asia" },
{ value: "in", label: "India", group: "Asia" },
{ value: "us", label: "United States", group: "Americas" },
],
groupBy: (item) => item.group,
});
<Select
collection={countries}
searchable
placeholder="Select a country"
onValueChange={({ value }) => setCountry(value[0])}
/>
Key props:
| Prop | Type | Default | Description |
|---|---|---|---|
collection | ListCollection<T> | — | Data + resolver functions from createListCollection |
value / defaultValue | string[] | — | Controlled / uncontrolled selection |
multiple | boolean | false | Allows multiple selected values |
searchable | boolean | false | Shows an in-list search box |
onSearch | (query: string) => void | — | Delegates filtering to the caller (e.g. API-backed search) |
searchLoading | boolean | false | Shows a "Searching…" empty state |
positioning | { placement, offset, sameWidth } | auto/4px | Web dropdown anchor behavior |
renderTrigger / renderOption | functions | — | Full control over trigger and row rendering |
variant / size | strings | config-derived | Visual treatment and scale |
onValueChange / onHighlightChange / onOpenChange | functions | — | Selection, keyboard-highlight, and open-state callbacks |
SimplePopover
A lighter-weight, Chakra-UI-flavored compound popover — Root, Trigger, Positioner, Content, Arrow, Header, Body, Footer, CloseTrigger — aimed at simple anchored panels (confirmations, info bubbles, small forms) rather than the full anchored-dropdown machinery Popover provides.
How it works internally:
Open state is delegated to a shared useDisclosure hook rather than reimplemented locally, which is what keeps SimplePopoverRoot itself thin: it only adds the controlled/uncontrolled branching and the trigger measurement on top. Measuring happens in the open callback via measureInWindow, capturing the trigger's on-screen rectangle before flipping visibility — so by the time Positioner mounts, it already has a rectangle to compute against rather than measuring after the fact and visibly jumping into place.
Positioner turns that rectangle plus a placement (one of eight positions, including "top-start"/"bottom-end" style corner variants) into an absolute top/left pair through a plain switch on the placement's primary side, with a secondary align (start/end) shifting the cross-axis position for corner placements. Unlike Popover's usePopoverPosition, this doesn't auto-flip based on available space — the placement is authoritative, keeping the component intentionally simpler for cases where the caller already knows which side has room.
The open/close motion runs on a single Animated.Value driven by a spring, with motionPreset swapping between a scale+fade combination, a fade-only version, or no animation at all — computed once via useMemo off motionPreset so switching presets doesn't reallocate the interpolation function on every render. Arrow derives its own triangle purely from CSS-style border tricks (three transparent border sides plus one colored side sized to size), positioned opposite whichever side the popover opened on, so an arrow on a "top"-placed popover correctly points down toward the trigger rather than up.
A dedicated useSimplePopover hook is exported specifically so content deep inside Footer or Body — like a custom "Cancel" button — can call close() without needing the trigger's own toggle handler threaded down as a prop.
<SimplePopover.Root placement="bottom-start" closeOnBlur>
<SimplePopover.Trigger asChild>
<Button label="Delete" />
</SimplePopover.Trigger>
<SimplePopover.Positioner>
<SimplePopover.Content>
<SimplePopover.Arrow />
<SimplePopover.Header>Delete this item?</SimplePopover.Header>
<SimplePopover.Body>This action can't be undone.</SimplePopover.Body>
<SimplePopover.Footer>
<SimplePopover.CloseTrigger>Cancel</SimplePopover.CloseTrigger>
<Button label="Delete" onPress={confirmDelete} />
</SimplePopover.Footer>
</SimplePopover.Content>
</SimplePopover.Positioner>
</SimplePopover.Root>
Key props:
| Prop | Component | Type | Default | Description | ||
|---|---|---|---|---|---|---|
isOpen / defaultIsOpen | Root | boolean | — | Controlled / uncontrolled visibility | ||
placement | Root | 8-way placement string | "bottom" | Anchor side + optional corner alignment | ||
closeOnBlur / closeOnEsc | Root | boolean | true | Backdrop-tap and Android-back dismissal | ||
asChild | Trigger | boolean | false | Clones the child instead of wrapping in TouchableOpacity | ||
offset / crossOffset | Positioner | number | 8 / 0 | Main-axis gap and cross-axis shift | ||
motionPreset | Positioner | `"scale" \ | "fade" \ | "none"` | "scale" | Enter/exit animation style |
minWidth / maxWidth | Content | number | 200 / 320 | Size bounds of the panel |
List
A themeable list system — List, ListItem, ListItemIndicator, ListItemMenu — covering both ordered/unordered display lists and a lightweight inline action menu, sharing one context so indicator styling stays consistent across a whole list.
How it works internally:
List itself renders nothing but a View and a context provider; all the per-row logic lives in ListItem and ListItemIndicator, which read variant, gap, align, and fontSize back out of ListContext instead of receiving them as props. Auto-numbering for variant="ol" is handled by walking children with React.Children.map and cloning every direct ListItem with an injected _index — an internal-only prop (documented as "do not set manually") that flows into ListItemIndicator to render "1.", "2.", and so on without the caller ever having to track a counter themselves. Because the walk only touches direct ListItem children, nesting an item inside an unrelated wrapper component would break the numbering — a deliberate simplicity tradeoff over supporting arbitrarily nested lists.
ListItemIndicator resolves its displayed content in a small chain: an explicit icon always wins over any label, an explicit label prop wins over the auto-generated one, and if neither is given it falls back to "{index}." for an ol or a plain bullet for a ul. This is a React.memo'd leaf component specifically because a list of any real length would otherwise re-render every indicator whenever the parent List re-renders for an unrelated reason.
ListItemMenu is the odd one out — a small vertical action list (think a native long-press menu) rather than a bulleted list item. Its per-row press feedback is a Pressable wrapping an Animated.View whose scale is driven by two module-level, pre-allocated spring configs (PRESS_IN_CONFIG/PRESS_OUT_CONFIG) rather than object literals created fresh per press — a small but real allocation saving in a component that might render many rows. Per-action press handlers are also memoized as a single array (handlers) keyed off actions and onSelect together, so passing a stable actions array down doesn't force every row's onPress to be recreated on every parent render.
<List variant="ol" gap={10}>
<ListItem>Preheat the oven to 220°C</ListItem>
<ListItem>Season the vegetables</ListItem>
<ListItem
indicator={<Icons.Flame size={16} color="orange" />}
>
Roast for 25 minutes
</ListItem>
</List>
<ListItemMenu
actions={["Rename", "Duplicate", "Delete"]}
onSelect={(action) => handleAction(action)}
/>
Key props (List):
| Prop | Type | Default | Description | |||
|---|---|---|---|---|---|---|
variant | `"ol" \ | "ul"` | "ul" | Numbered vs. bulleted indicators | ||
gap | `number \ | string` | theme-derived | Space between items | ||
align | `"left" \ | "center" \ | "right" \ | "stretch"` | "left" | Cross-axis alignment of rows |
Key props (ListItem):
| Prop | Type | Description |
|---|---|---|
indicator | ReactNode | Custom leading element, overrides the default bullet/number |
label | string | Overrides the auto-generated indicator text |
hideIndicator | boolean | Omits the leading slot entirely |
Key props (ListItemMenu):
| Prop | Type | Description |
|---|---|---|
actions | string[] | Row labels, rendered in order with dividers between them |
onSelect | (action: string) => void | Fires with the pressed row's label |
Portal
A single-purpose passthrough helper that exists purely so JSX shared with (or ported from) a Chakra/Ark-UI-based web design system compiles unchanged on React Native — nothing more.
How it works internally:
A web Portal re-parents children into a different DOM node to escape clipping or z-index limitations. React Native has no DOM and no equivalent limitation here, because every overlay in the library (Popover.Content, SimplePopover.Positioner, etc.) already renders through RN's Modal, which draws above the rest of the app regardless of where in the component tree it's mounted. That makes a real portal implementation unnecessary — Portal simply renders children in place, while PortalHost and usePortalContext are kept as no-ops purely so code written against the web API doesn't need to be rewritten or conditionally stripped when shared into a React Native project.
// Works identically with or without the wrapping <Portal> —
// kept here only because the same JSX is shared with the web version.
<Portal>
<SimplePopover.Positioner>
<MenuItems />
</SimplePopover.Positioner>
</Portal>
Exports:
| Export | Type | Description |
|---|---|---|
Portal | component | Renders children in place |
PortalHost | component | No-op View wrapper, kept for API compatibility |
usePortalContext | hook | Stub returning no-op mount/unmount, kept so direct imports don't break |
Separator
A small, themeable horizontal (or flex-filling) rule for dividing sections of content — the kind of one-line utility most libraries fold into a bigger component, split out here so it can be dropped anywhere on its own.
How it works internally:
There isn't much surface area, and that's the point: Separator resolves a single style object — height, width: "100%", flex: 1, and a backgroundColor — through useMemo, so a parent re-render doesn't force a new style array unless the resolved inputs actually changed. Color follows a three-step precedence: an explicit color prop wins, then the separator theme config's own color, then the theme's general mutedForeground — meaning an app that's never configured a separator entry still gets a sensible, on-theme default rather than a hardcoded gray. Margin values (margin, marginHorizontal, marginVertical) are pulled from config the same way but left undefined when unset, so React Native's own style-merging behavior applies rather than the component silently zeroing out spacing an app never asked it to touch.
Setting flex: 1 alongside a fixed height is deliberate: it lets the same component work either as a full-width divider inside a vertical stack, or as a thin vertical rule that stretches to fill available cross-axis space inside a flexDirection: "row" container, without needing a separate orientation prop to switch between the two.
<View style={{ gap: 12 }}>
<Text>Personal details</Text>
<Separator />
<Text>Billing details</Text>
</View>
// As a vertical rule between two row items
<View style={{ flexDirection: "row", height: 20 }}>
<Text>Home</Text>
<Separator style={{ width: 1, height: "100%", marginHorizontal: 8 }} />
<Text>Settings</Text>
</View>
Key props:
| Prop | Type | Default | Description |
|---|---|---|---|
color | string | theme mutedForeground | Line color |
style | ViewProps["style"] | — | Overrides for orientation, thickness, spacing |
| ...rest | ViewProps | — | Passed straight through to the underlying View |
Wrapping up
Together these six pieces cover a lot of common ground: a form-ready rating input, two different flavors of anchored overlay depending on how much control you need, a themeable list/menu system, and two small utilities that exist to keep code portable and layouts tidy. All are available now and ship with no additional peer dependencies beyond what the library's other overlay- and animation-driven components already require (react-native-reanimated for Select's web dropdown animation).
More components are in active development and will be covered in the next release post as soon as they land.