@retor/react-native
v0.4.6
Published
React Native SDK for embedding Retor 3D experiences
Maintainers
Readme
@retor/react-native
Embed Retor 3D experiences in a React Native / Expo app — with composable bottom-sheet UI built on @gorhom/bottom-sheet.
Installation
# Expo
npx expo install react-native-webview react-native-gesture-handler react-native-reanimated react-native-svg expo-blur
npm install @gorhom/bottom-sheet lucide-react-native @retor/react-native
# bare React Native
npm install react-native-webview react-native-gesture-handler react-native-reanimated react-native-svg @gorhom/bottom-sheet lucide-react-native expo-blur @retor/react-native
cd ios && pod install
expo-bluris optional — it's used for the default blurred sheet background. Skip it if you don't want blur and pass a custombackgroundComponentto any sheet.
You also need to wrap your app root in a GestureHandlerRootView (per @gorhom/bottom-sheet requirements):
import { GestureHandlerRootView } from "react-native-gesture-handler";
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<YourApp />
</GestureHandlerRootView>
);
}Quick Start — Default UI
The fastest way: drop in <Hud> with the three sheets and they'll work out of the box.
import { View } from "react-native";
import { Viewer, Hud, ProjectSheet, LineDetailSheet, AddNoteSheet } from "@retor/react-native";
export default function Scene() {
return (
<View style={{ flex: 1 }}>
<Viewer projectId="abc123">
<Hud>
<ProjectSheet />
<LineDetailSheet />
<AddNoteSheet />
</Hud>
</Viewer>
</View>
);
}What you get:
- A bottom sheet showing the project name and a horizontal carousel of lines
- Tap a line → second sheet opens with the tag list and a Done button
- Tap a tag → camera scrolls to it
- Done returns to the browse sheet
Concepts
<Viewer>wraps the WebView and exposes scene state via React context. Always vanilla — Retor itself shows no UI.<Hud>sets upBottomSheetModalProvider. Place sheets and other overlays inside.- Sheets auto-present based on bridge state:
<ProjectSheet>shows when no line is open<LineDetailSheet>shows when a line is open<AddNoteSheet>shows whenuseAddNote().open()is called
- Composition components (
<LinesCarousel>,<LineTagList>) take a render prop so you can replace the visuals while keeping the data wiring.
Customising the visuals
Each sheet accepts:
snapPoints— override the default snap pointsrenderHeader— replace the headerchildren— replace the body (typically a render-prop list)
import { Pressable, Text } from "react-native";
import { ProjectSheet, LinesCarousel, LineDetailSheet, LineTagList, useViewer } from "@retor/react-native";
function MyLineCard({ line }: { line: RetorLine }) {
const { openLine } = useViewer();
return (
<Pressable
onPress={() => openLine(line._id)}
style={{ width: 200, padding: 16, backgroundColor: "#222", borderRadius: 16 }}
>
<Text style={{ color: "white", fontWeight: "600" }}>{line.name}</Text>
</Pressable>
);
}
<ProjectSheet snapPoints={["20%", "50%"]}>
<LinesCarousel>
{(line) => <MyLineCard line={line} />}
</LinesCarousel>
</ProjectSheet>
<LineDetailSheet>
<LineTagList>
{(tag, isActive) => (
<Pressable style={{ padding: 12 }}>
<Text style={{ color: isActive ? "white" : "gray" }}>{tag.name}</Text>
</Pressable>
)}
</LineTagList>
</LineDetailSheet>Notes
<AddNoteSheet> triggers when you call useAddNote().open(tagId?). It collects text + private/public, then either:
- calls
onNoteSubmiton the parent<Viewer>(if set) — persistence is your responsibility - or falls back to persisting via Retor's own backend (Convex) when no
onNoteSubmitis provided
Re-pass saved notes back to the 3D scene via <Notes>.
Note fields for proper rendering
When passing notes via <Notes>, each note should include:
| Field | Required | Purpose |
|-------|----------|---------|
| _id | yes | Unique identifier |
| name | yes | The note text (displayed in the tag list and 3D scene) |
| position | yes | { x, y, z } — where the note sits on the line |
| objectId | yes | Set to the lineId so the note associates with the correct line |
| progress | yes | 0..1 position along the line (from the submit payload) — used for sort order and scroll-to |
| avatarUrl | recommended | Profile image URL — renders as a circular avatar in the tag pill and list item |
| authorName | recommended | Display name — used as initial-letter fallback when no avatarUrl |
| tagType | recommended | Set to "icon" for the standard note pill appearance |
| userId | for deletion | The note author's user ID — compared against Viewer.userId to show the delete button |
Creating + deleting notes
import { useState } from "react";
import { Viewer, Hud, ProjectSheet, LineDetailSheet, AddNoteSheet, Notes, type RetorTag } from "@retor/react-native";
export default function Scene() {
const [notes, setNotes] = useState<RetorTag[]>([]);
const currentUserId = "user_abc"; // your auth system's user ID
return (
<Viewer
projectId="abc123"
userId={currentUserId}
onNoteSubmit={({ text, isPrivate, lineId, position, progress }) => {
if (!position) return;
setNotes((prev) => [
...prev,
{
_id: `note-${Date.now()}`,
name: text,
position,
progress,
objectId: lineId ?? undefined,
tagType: "icon",
avatarUrl: "https://example.com/avatar.jpg",
authorName: "Jane",
userId: currentUserId,
},
]);
}}
onNoteDelete={(noteId) => {
setNotes((prev) => prev.filter((n) => n._id !== noteId));
// also delete from your backend
}}
>
<Notes notes={notes} />
<Hud>
<ProjectSheet />
<LineDetailSheet />
<AddNoteSheet />
</Hud>
</Viewer>
);
}When onNoteSubmit is not provided, the SDK sends the note to Retor's backend automatically (using the signed-in Clerk session inside the WebView). No <Notes> re-injection needed in that case.
When a user deletes a note, the SDK:
- Optimistically removes it from the local tag list
- Fires
onNoteDelete(noteId)so you can delete from your backend
Hooks
All hooks read from the bridge context provided by the parent <Viewer>.
| Hook | Returns |
|------|---------|
| useProject() | The current RetorProject (or null) |
| useLines() | Array of RetorLine |
| useActiveLine() | The currently open line (or null) |
| useLineProgress() | { progress, closestTagId } |
| useAutoplay() | { isPlaying, toggle, play, pause } |
| useAddNote() | { isOpen, tagId, open, close, submit } |
| useViewer() | Imperative controls (openLine, exitLine, scrollToTag, scrollToProgress, ...) |
Imperative API
The useViewer hook also supports controlling a specific viewer by ID:
<Viewer id="left" projectId="..." />
<Viewer id="right" projectId="..." />
const left = useViewer("left");
left.openLine("line-a");Or pass a ref directly:
const ref = useRef<ViewerHandle>(null);
<Viewer ref={ref} projectId="..." />
ref.current?.openLine("...");Cover photo
A static thumbnail of a project's start view — no 3D, no bridge:
import { CoverPhoto } from "@retor/react-native";
<CoverPhoto projectId="abc123" style={{ width: 200, height: 120 }} />License
MIT
