Five New UI Primitives in rn-markdown-editor — PinInput, Popover, NativeSelect, Portal, and QRCode
This release rounds out a gap that's been open since the early days of the library: verification-style inputs, anchored overlays, and a couple of small but frequently-requested utilities. Five components land in this version — a segmented PIN/OTP field, a compound anchored-popover primitive, a searchable native-style select, a passthrough portal helper, and a themeable QR code renderer. Here's how each one is built and how to use it.
PinInput
A segmented one-time-code / PIN field — the box-per-character input you'd wire up for SMS verification, 2FA codes, or any fixed-length code entry. It ships as a single component rather than a compound one, since there's nothing to compose: you get a row of boxes and a value.
How it works internally:
Each box is a real TextInput capped at a generous maxLength of 6, not 1. That's a deliberate overshoot: autofill and paste on some devices dump the entire code into whichever box currently has focus rather than distributing it character-by-character, and a maxLength={1} field would silently truncate that to its first character. handleChangeText checks text.length > 1 up front and, when it fires, walks the pasted string character-by-character, validating each one against charRegex and writing it into successive boxes starting at the field that received the paste — so a six-digit SMS code autofilled into box one still lands correctly across all six boxes.
Validation is driven by a type prop (numeric / alphabetic / alphanumeric), each backed by a static regex, but an explicit pattern prop overrides that regex entirely for custom character sets. Characters that fail the check never reach state — instead onValueInvalid fires with the offending index, so the caller can trigger a shake animation or error state without the invalid character ever appearing in a box.
Backspace handling is intentionally asymmetric from typing: on an empty box, Backspace clears the previous box and moves focus back to it, matching the "delete reaches backward" behavior people expect from native OTP fields on iOS and Android rather than requiring two presses per box to actually erase something. Completion is detected by checking that every box has a non-empty value; once true, onValueComplete fires and, if blurOnComplete is set (the default), focus moves off the last field automatically, which is what lets a submit handler fire on completion without an explicit "Submit" tap.
Value can be controlled (value) or uncontrolled (defaultValue), and count — the number of boxes — falls back to whichever of count, value.length, or defaultValue.length is defined first, defaulting to 4 if none are.
const otpRef = useRef<PinInputRef>(null);
<PinInput
ref={otpRef}
otp
type="numeric"
count={6}
autoFocus
onValueComplete={({ valueAsString }) => verifyCode(valueAsString)}
onValueInvalid={({ index }) => shakeBox(index)}
/>
// Clear the field after a failed verification
otpRef.current?.clear();
Key props:
| Prop | Type | Default | Description | ||
|---|---|---|---|---|---|
value / defaultValue | string[] | — | Controlled / uncontrolled per-box values | ||
count | number | 4 | Number of boxes | ||
type | `"numeric" \ | "alphabetic" \ | "alphanumeric"` | "numeric" | Default character validation |
pattern | string | — | Overrides type's regex | ||
otp | boolean | false | Enables autoComplete="one-time-code" / iOS oneTimeCode | ||
mask | boolean | false | Renders filled digits as • via secureTextEntry | ||
blurOnComplete | boolean | true | Blurs the last field once every box is filled | ||
selectOnFocus | boolean | false | Selects a box's existing value when it's focused | ||
attached | `"true" \ | "false"` | "false" | Adjacent boxes share a border instead of being spaced | |
variant | `"outline" \ | "subtle" \ | "flushed"` | "outline" | Visual treatment |
size | "2xs" – "2xl" | "md" | Box size, font size, and radius scale | ||
colorPalette | one of 10 named colors | "gray" | Focus/invalid border color | ||
invalid | boolean | false | Forces the error border color | ||
onValueChange / onValueComplete / onValueInvalid | functions | — | Change, completion, and rejected-character callbacks |
Popover
A compound, anchored-overlay primitive — Root, Trigger, Content — for dropdowns, menus, tooltips, and any floating panel that needs to track a trigger element's on-screen position. It's the lower-level building block that things like a filter menu or an inline color picker get built on top of.
How it works internally:
Root doesn't render any UI of its own — it's a context provider that measures the trigger and tracks open state. Pressing Trigger calls measureAndOpen, which uses measureInWindow on the trigger's ref to capture its absolute screen position before flipping open to true, so Content always has accurate coordinates to anchor against by the time it renders, rather than measuring after the fact and causing a visible jump.
Content renders through a transparent, full-screen RN Modal — the same escape-the-view-hierarchy trick used elsewhere in the library — with a Pressable backdrop that closes the popover on outside press (stopPropagation on the content itself prevents that same press from bubbling to the backdrop). Positioning math lives in a dedicated usePopoverPosition hook: it takes the measured trigger rect, the content's own rendered size (captured via onLayout, since the true content dimensions aren't known until the first paint), and the screen dimensions, then computes top/left/width along with whichever max-width/max-height the content should clamp to.
Placement defaults to flipping automatically: if placement is "auto" (or unset) and the content doesn't fit in the space below the trigger, but there's more room above than below, the popover flips to open upward — the same "auto-flip near the bottom of the screen" behavior a web dropdown gets from Popper/Floating UI. Width can also be locked to the trigger's own width via sameWidth, which is what a select-style dropdown needs to look anchored rather than floating arbitrarily wide or narrow.
The open/close transition runs on the UI thread via Reanimated: contentOpacity and contentTranslate are shared values animated with withTiming, and the translate direction is flipped depending on openAbove so the content always animates in from the direction it's anchored to, rather than sliding the wrong way when the popover flips.
function FilterMenu() {
const [selected, setSelected] = useState<string | null>(null);
return (
<Popover.Root>
<Popover.Trigger>
<Button label="Filter" />
</Popover.Trigger>
<Popover.Content maxWidth={240} positioning={{ placement: "auto", offset: 8 }}>
{["Active", "Archived", "Draft"].map((option) => (
<Pressable key={option} onPress={() => setSelected(option)}>
<Text>{option}</Text>
</Pressable>
))}
</Popover.Content>
</Popover.Root>
);
}
Key props:
| Prop | Component | Type | Default | Description |
|---|---|---|---|---|
open / defaultOpen | Root | boolean | — | Controlled / uncontrolled visibility |
onOpenChange | Root | (details: { open: boolean }) => void | — | Fires on every open-state change |
lazyMount / unmountOnExit | Root | boolean | false / true | Mount timing controls |
asChild | Trigger | boolean | false | Clones the child instead of wrapping it in a Pressable |
positioning | Content | { placement, offset, sameWidth, edgePadding } | { placement: "bottom", offset: 8 } | Anchor side, gap, and width-matching |
maxWidth / maxHeight | Content | number | viewport-derived | Caps that shrink automatically to fit the screen |
showBackdrop | Content | boolean | false | Dims the screen behind the content |
closeOnInteractOutside | Content | boolean | true | Dismiss on outside press |
NativeSelect
A JSX-first wrapper around the library's lower-level Select primitive, letting you declare options as children — NativeSelect.Option — instead of building an items array by hand. It exists purely to make authoring a select feel like writing markup rather than assembling a data collection.
How it works internally:
NativeSelectOption and NativeSelectOptionsList are both components that return null — they're never actually rendered. Their only job is to exist as recognizable elements in the JSX tree so NativeSelectRoot can walk children, find the OptionsList, and recursively collect every Option beneath it into a flat array of items, regardless of how deeply they're nested inside fragments or wrapper components. This is the same "children as configuration, not as render output" pattern used by things like <select><option> on the web, adapted to a tree that has no native select element to delegate to.
Each collected option carries a label used for search/typeahead and for the trigger's closed-state text. When children on an Option is a plain string, that string doubles as the label automatically; when it's rich JSX (an icon plus text, for instance), an explicit label prop is required so typeahead and the collapsed trigger still have plain text to work with — there's no way to derive readable text from arbitrary JSX. The collected items are handed to createListCollection, the same collection shape the underlying Select primitive expects, wired up with itemToValue, itemToString, itemToDisabled, and groupBy accessors resolved from each item's parsed props.
Rendering a custom row is a two-step handoff: NativeSelect resolves a renderOption(item, state) function down to the underlying Select, and that function either delegates to a caller-supplied renderOption(content, state) or, for plain-string options, returns undefined so Select falls back to its own default text rendering rather than re-wrapping a string in an extra <Text>.
Theming resolution follows the same three-tier pattern as Select itself: an explicit variant/size prop wins, then the nativeSelect theme config's defaultVariant/defaultSize, then whatever Select itself defaults to — with size- and variant-specific overrides (borderRadius, height, paddingHorizontal, background/border colors) merged into the trigger's style only when they're actually present in config, so an unconfigured app doesn't pay for a style object full of undefineds.
<NativeSelect.Root
placeholder="Select a country"
onValueChange={({ value }) => setCountry(value[0])}
>
<NativeSelect.OptionsList>
<NativeSelect.Option value="np" group="Asia">Nepal</NativeSelect.Option>
<NativeSelect.Option value="in" group="Asia">India</NativeSelect.Option>
<NativeSelect.Option value="us" group="Americas">United States</NativeSelect.Option>
<NativeSelect.Option value="ca" group="Americas" disabled>
Canada
</NativeSelect.Option>
</NativeSelect.OptionsList>
</NativeSelect.Root>
Key props (NativeSelect.Root):
| Prop | Type | Default | Description |
|---|---|---|---|
children | NativeSelect.OptionsList containing NativeSelect.Options | — | Declarative option source |
renderOption | (content: ReactNode, state) => ReactNode | — | Custom row renderer, receives the option's original JSX |
variant | inherited from Select | config-derived | Visual treatment of the trigger |
size | inherited from Select | config-derived | Trigger size scale |
| ...rest | inherited from Select (minus collection/renderOption) | — | Everything else passes straight through to Select |
NativeSelect.Option props:
| Prop | Type | Description |
|---|---|---|
value | string | Value submitted/selected for this option |
children | ReactNode | Row content; also the label when it's a plain string |
label | string | Required plain-text label when children is JSX |
group | string | Options sharing a group render under one header |
disabled | boolean | Disables the option |
Portal
A single-purpose passthrough helper that exists so code shared with (or ported from) a Chakra/Ark-UI-based web design system compiles unchanged on React Native — nothing more.
How it works internally:
On web, a Portal typically re-parents its children into a different DOM node so they can escape clipping or stacking-context limitations. React Native has no DOM and no equivalent limitation for this library's overlays, because every overlay component (Popover.Content, FloatingPanel, etc.) already renders through RN's Modal, which always 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 here just renders children in place, and PortalHost/usePortalContext are kept as no-ops purely so that code written against the web API (<Portal><Popover.Positioner>... etc.) doesn't need to be rewritten or conditionally stripped when it's shared into an Expo/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>
<Popover.Content>
<MenuItems />
</Popover.Content>
</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 |
QRCode
A themeable QR code renderer built on react-native-qrcode-svg, with variant-based coloring, an optional center overlay (for a logo), a loading state, and optional press handling — the pattern you reach for on a payment screen, a "scan to connect" flow, or a shareable profile link.
How it works internally:
Color resolution happens in getVariantColors, a small pure function (not a hook, since it needs no lifecycle) that maps a variant (default / accent / inverted / custom) to a { color, backgroundColor } pair drawn from the app's theme colors, then lets an explicit color/backgroundColor prop — or a config-level override for that variant — take final precedence. inverted swaps foreground and background entirely, which is the variant you'd reach for rendering a code on a dark surface without it disappearing.
The actual QR rendering is split into a separate QRContent component wrapped in React.memo, specifically so the outer QRCode component can conditionally wrap it in a Pressable (when onPress/onClick is supplied) without that Pressable shell causing the — comparatively expensive — SVG regeneration to re-run on every render of the parent. All of QRContent's own derived styles (outerStyle, qrContainerStyle, overlayContainerStyle) are individually memoized against just the primitive values they depend on, rather than the whole props object, so a parent re-render that doesn't change size/color/overlay doesn't force new style objects either.
The overlay — typically a small logo dropped in the center — is sized as a ratio of the code's own size by default (22%) rather than a fixed pixel value, so it scales proportionally if the code itself is resized, and its corner radius likewise defaults to a ratio of its own size. When an overlay is present, errorCorrectionLevel defaults to "H" (highest) automatically unless explicitly overridden, since covering part of the code with a logo needs the extra error-correction redundancy to stay scannable — a "M" code with a logo dropped on top is a common way to end up with an unreadable QR code, so the component defaults away from that failure mode rather than requiring the caller to know to set it.
A loading state swaps the whole code out for an ActivityIndicator (or a custom loadingIndicator) sized to match the code's own dimensions, so a QR code that depends on an async-generated link doesn't flash an empty/invalid code before the real value arrives.
<QRCode
link="https://example.com/pay/inv_12345"
size={220}
variant="accent"
overlay={<CompanyLogo width={32} height={32} />}
loading={isGeneratingLink}
onPress={() => Share.share({ url: link })}
/>
Key props:
| Prop | Type | Default | Description | |||
|---|---|---|---|---|---|---|
link | string | — | The encoded value | |||
size | number | 200 | Code size in px | |||
variant | `"default" \ | "accent" \ | "inverted" \ | "custom"` | "default" | Color pairing sourced from theme |
color / backgroundColor | string | variant-derived | Explicit overrides | |||
errorCorrectionLevel | `"L" \ | "M" \ | "Q" \ | "H"` | "H" with overlay, else "M" | QR error-correction redundancy |
overlay | ReactNode | — | Content centered over the code (e.g. a logo) | |||
overlaySize / overlayBorderRadius / overlayBackgroundColor | number / number / string | ratio of size | Overlay sizing and styling | |||
quietZone | number | 0 | Padding between the code and its background edge | |||
loading / loadingIndicator | boolean / ReactNode | false / ActivityIndicator | Loading state and its indicator | |||
onPress / onClick | () => void | — | Wraps the code in a Pressable when set | |||
borderRadius | number | 0 | Corner radius of the background container |
Installation
All five components are available now:
npm install rn-markdown-editor
or
yarn add rn-markdown-editor
Popover additionally depends on react-native-reanimated, and QRCode depends on react-native-qrcode-svg — both should already be installed if you're using the library's other gesture- and overlay-driven components. PinInput, NativeSelect, and Portal have no additional peer dependencies beyond the library's existing core.
More components are in active development and will be covered in the next release post as soon as they land.