Five New UI Primitives in rn-markdown-editor — PinInput, Popover, NativeSelect, Portal, and QRCodesteemCreated with Sketch.

in Steem Dev9 days ago

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:

PropTypeDefaultDescription
value / defaultValuestring[]Controlled / uncontrolled per-box values
countnumber4Number of boxes
type`"numeric" \"alphabetic" \"alphanumeric"`"numeric"Default character validation
patternstringOverrides type's regex
otpbooleanfalseEnables autoComplete="one-time-code" / iOS oneTimeCode
maskbooleanfalseRenders filled digits as via secureTextEntry
blurOnCompletebooleantrueBlurs the last field once every box is filled
selectOnFocusbooleanfalseSelects 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
colorPaletteone of 10 named colors"gray"Focus/invalid border color
invalidbooleanfalseForces the error border color
onValueChange / onValueComplete / onValueInvalidfunctionsChange, 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:

PropComponentTypeDefaultDescription
open / defaultOpenRootbooleanControlled / uncontrolled visibility
onOpenChangeRoot(details: { open: boolean }) => voidFires on every open-state change
lazyMount / unmountOnExitRootbooleanfalse / trueMount timing controls
asChildTriggerbooleanfalseClones the child instead of wrapping it in a Pressable
positioningContent{ placement, offset, sameWidth, edgePadding }{ placement: "bottom", offset: 8 }Anchor side, gap, and width-matching
maxWidth / maxHeightContentnumberviewport-derivedCaps that shrink automatically to fit the screen
showBackdropContentbooleanfalseDims the screen behind the content
closeOnInteractOutsideContentbooleantrueDismiss 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):

PropTypeDefaultDescription
childrenNativeSelect.OptionsList containing NativeSelect.OptionsDeclarative option source
renderOption(content: ReactNode, state) => ReactNodeCustom row renderer, receives the option's original JSX
variantinherited from Selectconfig-derivedVisual treatment of the trigger
sizeinherited from Selectconfig-derivedTrigger size scale
...restinherited from Select (minus collection/renderOption)Everything else passes straight through to Select

NativeSelect.Option props:

PropTypeDescription
valuestringValue submitted/selected for this option
childrenReactNodeRow content; also the label when it's a plain string
labelstringRequired plain-text label when children is JSX
groupstringOptions sharing a group render under one header
disabledbooleanDisables 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:

ExportTypeDescription
PortalcomponentRenders children in place
PortalHostcomponentNo-op View wrapper, kept for API compatibility
usePortalContexthookStub 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:

PropTypeDefaultDescription
linkstringThe encoded value
sizenumber200Code size in px
variant`"default" \"accent" \"inverted" \"custom"`"default"Color pairing sourced from theme
color / backgroundColorstringvariant-derivedExplicit overrides
errorCorrectionLevel`"L" \"M" \"Q" \"H"`"H" with overlay, else "M"QR error-correction redundancy
overlayReactNodeContent centered over the code (e.g. a logo)
overlaySize / overlayBorderRadius / overlayBackgroundColornumber / number / stringratio of sizeOverlay sizing and styling
quietZonenumber0Padding between the code and its background edge
loading / loadingIndicatorboolean / ReactNodefalse / ActivityIndicatorLoading state and its indicator
onPress / onClick() => voidWraps the code in a Pressable when set
borderRadiusnumber0Corner 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.