Improved File Upload, Marquee & Portal components in rn-markdown-editorsteemCreated with Sketch.

in Steem Devyesterday

FileUpload

Tags: compound-component · form-control · file-picker · drag-and-drop · media-preview · cross-platform

A Radix-style compound file-input primitive for React Native (Expo/web via NativeWind) — a shared useFileUpload hook backing interchangeable trigger surfaces (button, dropzone, URL paste), plus a themed list of picked files with image/video previews.

import { FileUpload } from "rn-markdown-editor/components";

<FileUpload.Root onFilesChange={setFiles} accept={["image/*"]} preview>
  <FileUpload.Label>Attachments</FileUpload.Label>
  <FileUpload.Dropzone />
  <FileUpload.ItemGroup />
  <FileUpload.ClearTrigger />
</FileUpload.Root>

// or the convenience wrapper:
<FileUpload.Field variant="dropzone" label="Attachments" preview />

How it works internally

One hook backs the entire subtree. Root calls useFileUpload(props) once and pushes the hook's return value — plus the raw fieldProps (the props Root was given) — into FileUploadContext as a single memoized object. Every other subcomponent (Trigger, Dropzone, Item, Label, ClearTrigger, UrlInput) reads from this one context via useFileUploadCtx() rather than receiving props directly, so Root is the only stateful node in the tree; everything below it is a pure presentation of shared state.

Root itself renders nothing but a themed View. It sets nativeID, accessibilityState={{ disabled }}, and aria-invalid/aria-required on the wrapping View, but has no layout or picker logic of its own — that all lives in the hook and in whichever trigger subcomponents are composed inside it.

Drag-and-drop is wired by ref, not by props. Dropzone attaches a dropzoneRef (returned from the hook) directly to the underlying Pressable. Per the implementation comment, this lets native dragenter/dragover/dragleave/drop DOM listeners be attached straight to the underlying element from inside useFileUpload, rather than trying to route those browser-only events down through RN prop plumbing.

ConditionalDropzone is a genuine platform fork, not a shared shell. Unlike components elsewhere in the library that use one Modal-based shell for both platforms, ConditionalDropzone explicitly branches on Platform.OS: real drag-and-drop (Dropzone) only exists on web, since native has no drag events, so native falls back to rendering Trigger (a plain tap-to-browse button) instead.

File "kind" is re-sniffed, not trusted from the picker. Item calls resolveFileKind(file) rather than reading file.type directly, because — per an inline comment — expo-document-picker frequently reports a missing or generic application/octet-stream mime on web/Android, which would otherwise make every image or video silently fall back to the plain file row. resolveFileKind re-derives the kind from the filename extension whenever the reported mime is unusable.

Preview rendering branches on the re-sniffed kind, and picks a dedicated component per media type. showPreview requires both the preview field prop and a non-null kind; images render through expo-image's Image, while videos get a separate VideoPreview subcomponent wrapping expo-video's useVideoPlayer/VideoView, configured muted and non-looping with native controls disabled (it's a preview thumbnail, not a player).

Empty state is a hard null, not an empty container. ItemGroup returns null when files.length is 0 rather than rendering an empty View with gap styling — avoiding a phantom margin/gap appearing before any files exist.

UrlInput owns its own draft value. Unlike the rest of the tree, UrlInput keeps a local useState for the in-progress URL string; only on submit does it call the context's addFromUrl(value) and clear itself — the picked-files list itself still lives entirely in the shared hook state.

Field is a pure variant switch, not a variant-aware primitive. It composes Root + Label + one of Dropzone/Trigger/UrlInput/ConditionalDropzone (based on the variant prop) + ItemGroup, and holds no state of its own — it exists purely so consumers can skip hand-composing the primitives for common layouts.

Key props — FileUpload.Root / Field (FieldUploadProps)

PropDescription
variant (Field only)`"dropzone" \"button" \"input" \"urlinput" \"conditionalDropdown"— selects which trigger surfaceField` renders
label (Field only)Rendered via Label above the trigger surface
previewEnables image/video thumbnails in Item
disabled / readOnlyBlocks Trigger/Dropzone presses; Root reflects disabled into accessibilityState
invalid / requiredReflected as aria-invalid / aria-required, and drive the trailing * in Label
translationsOverride strings: browseLabel, dropzoneLabel, invalidTypeError, removeLabel, clearAllLabel, urlInputPlaceholder, urlInputAddLabel
idsExplicit nativeIDs for root, trigger, dropzone, label, and a per-file item(uri) function
styleMerged onto Root's outer View

Key subcomponents

ComponentRole
TriggerTap-to-browse button; colors derived from config.colors.default with theme fallbacks
DropzoneWeb-only drag target; border/background react to isDragging/isDragReject
ConditionalDropzoneDropzone on web, Trigger on native
UrlInputText field + Add button that appends a file from a pasted URL
ItemGroup / ItemRenders the picked-file list; Item shows a preview, name, size, and remove button
LabelField label, with required-asterisk
ClearTriggerRemoves all files; renders null when the list is empty

Marquee

Tags: compound-component · animation · reanimated · gesture-handler · infinite-scroll · ticker

A continuously-scrolling ticker built on react-native-reanimated and react-native-gesture-handler — duplicates its content to create a seamless loop, supports all four scroll directions, edge fades, and press-to-pause.

import { Marquee } from "rn-markdown-editor/components";

<Marquee.Root speed={80} direction="left" gap={24}>
  <Marquee.Viewport fade fadeWidth={40}>
    <Marquee.Content>
      {logos.map((logo) => (
        <Marquee.Item key={logo.id} px={16}>
          <Logo source={logo.src} />
        </Marquee.Item>
      ))}
    </Marquee.Content>
  </Marquee.Viewport>
</Marquee.Root>

How it works internally

The loop is seamless because the content is literally duplicated, not because of clever math. Content renders its children twice inside the animated wrapper: once inside a measured View (onLayout reports its width/height into contentSize), and once more as an accessibilityElementsHidden duplicate placed immediately after it via gapStyle margin. The animation only ever needs to travel exactly contentSize + gap before the visual loop repeats, because the second copy is already sitting right where the first one "was."

Restarting the animation resumes in place instead of snapping. The effect that drives offset doesn't just call withRepeat from zero whenever speed, gap, or running changes. It reads offset.value % distance to find how far the current lap has already traveled, computes the remaining distance and a proportionally scaled remainingDuration, and animates just that remainder at the current speed — only switching to an infinite withRepeat once that in-progress lap finishes. This is what keeps the marquee from visibly jumping when you change speed or resume from pause mid-scroll.

Press-to-pause is a zero-duration long-press, not a Pressable. touchGesture is Gesture.LongPress().minDuration(0), so onBegin fires on touch-down rather than after a hold — giving press-and-hold-to-pause behavior. maxDistance(100000) and shouldCancelWhenOutside(false) keep the gesture engaged even if the finger drags off the marquee content entirely, so lifting the finger anywhere still correctly resumes the animation via onFinalize.

"Running" is derived state, not stored state. running = !paused && !isPressed combines the controlled paused prop with the internally-tracked isPressed (set via runOnJS from the gesture callbacks); the animation effect keys off this single derived boolean rather than tracking pause reasons separately.

Direction is one axis flag plus one sign flag, not four separate code paths. isHorizontal(direction) decides whether measurements/transforms use width/translateX or height/translateY; reversed = direction === "right" || direction === "down" just flips the multiplier (sign) applied to the animated offset in useAnimatedStyle. Left/up and right/down share identical math modulo that one sign.

Context carries configuration, not animation machinery. MarqueeContext exposes speed, direction, gap, pauseOnPress, paused, contentSize, and setContentSize — the offset shared value, the gesture object, and the animated-style computation all live locally inside Content and are never exposed, so no sibling can reach in and desync the running animation.

Fade edges are precomputed gradient descriptors, not inline JSX. Viewport builds leadingGradient/trailingGradient objects (colors, start/end points, absolute-position style) in useMemos keyed on fadeColor, the derived transparent color, horizontal, and fadeWidth, then spreads each straight onto a LinearGradient. hexToTransparent only handles #rgb/#rrggbb input — any other color format falls through to the literal string "transparent", which can produce a visible hard edge instead of a color-matched fade if fadeColor isn't a hex string.

Everything is React.memo'd and style arrays are memoized against their real dependencies (e.g. outerStyle, firstCopyStyle, itemStyle), consistent with the isolate-state-changes pattern used elsewhere in the library.

Key props

ComponentPropDefaultDescription
Rootspeed60Travel speed in px/sec
Rootdirection"left"`"left" \"right" \"up" \"down"`
Rootgap32Space between the real content and its duplicate
RootpauseOnPresstrueWhether touch-and-hold pauses the animation
RootpausedfalseControlled pause, independent of press state
ViewportfadefalseRenders leading/trailing LinearGradient overlays
ViewportfadeWidth32Width (or height, if vertical) of each fade edge
ViewportfadeColor"#ffffff"Solid color the fade blends into; must be hex for a clean fade
Contentstyle / childrenReads all animation config from context
Itempx / py0Padding around a single marquee entry

Portal

Tags: passthrough · no-op · cross-platform-compat · overlay · api-shim

A zero-dependency compatibility shim that lets Chakra-UI-style <Portal>/<PortalHost> usage compile and run on React Native — where, unlike on web, there's nothing for it to actually do.

import { Portal } from "rn-markdown-editor/components";

<Portal>
  <Popover.Positioner>...</Popover.Positioner>
</Portal>

How it works internally

There's nothing to "portal" on native, because Modal already escapes the tree. Overlay content in this library (e.g. Popover.Positioner) renders through React Native's Modal, which always renders above the entire view hierarchy regardless of where it sits structurally. So Portal doesn't need to move its children anywhere — it just renders <>{children}</> in place, exactly like Chakra UI's own no-op RN portal.

PortalHost and usePortalContext are compile-time stubs, not runtime infrastructure. PortalHost renders a plain View around its children (ignoring any notion of "host" or "target"), and usePortalContext returns stub mount/unmount functions that do nothing. Both exist purely so code carried over from a web/Chakra codebase — which might reference <PortalHost> or call usePortalContext() — continues to typecheck and run without a real portal-target registry ever being wired up.

portalKey is accepted but unused. It's kept on PortalProps only for API-shape parity with Chakra's Portal (where a key targets one of several possible hosts); on native there's effectively only ever one "host" — wherever Portal happens to be rendered — so the prop has no effect.

There is no shared context or registry anywhere in the file. Unlike a typical portal implementation, Portal and PortalHost don't communicate with each other at all — nesting one inside the other has no special interaction; each is independently a passthrough.

Key props

ComponentPropDescription
PortalchildrenRendered in place, unchanged
PortalportalKeyAccepted for API compatibility; has no effect
PortalHostchildren + ViewPropsRendered inside a plain View; all ViewProps pass through
usePortalContext()Returns no-op { mount, unmount }; kept only so existing imports resolve