About this animation
A draggable circular text ribbon with a stationary center and configurable copy, radius, palette, and fonts.
How it works
Actual proportional glyph widths determine the arc layout. Accessible 30-degree controls offer the same interaction without dragging. A single pink separator crosses twelve o'clock and reveals a 24-degree edge light once per release. Local measurement, a complete Paper/Night palette, and a readable flow layout make the ribbon usable beyond a full-screen demo. No reference artwork or font is included.
- Invisible native
Textnodes measure each distinct glyph withonLayout, including on web. Font size is 17 pt, tracking is 0.04 em. - The local slot limits the radius using its smaller dimension. Whole phrase repetitions are removed before tracking is tightened; an overlong phrase becomes ordinary text instead of shrinking the type.
- Glyph centers follow cumulative advance widths divided by radius. The first separator begins 65 degrees before twelve; all remaining glyphs use the regular ink color.
- One
rotationshared value controls the ribbon. Gesture angles unwrap at the seam; tangential release velocity driveswithDecaywith deceleration 0.995. - Crossing twelve after release reveals an actual curved edge stroke for 360 ms. Pressure and highlight clocks do not alter rotation. Buttons use an interruptible spring with explicit mass; the Paper separator mixes the accent with theme ink for contrast.
- The demo alone calls the ref: rest 600 ms, rotate 80 degrees over 700 ms, settle, reset at 3900 ms over 300 ms, repeat at 5000 ms. Direct input cancels every timer.
Install
npx expo install @shopify/react-native-skia@2.14.0 react-native-gesture-handler@~3.3.0 react-native-reanimated@4.7.1 react-native-worklets@0.13.0The host must provide GestureHandlerRootView. For web, initialize CanvasKit before mounting this component, following the host project's Skia setup. The optional demo also uses expo-linear-gradient and the catalog's preloaded Geist, Instrument Serif, and Geist Mono fonts. The component has no theme or catalog imports.
Usage
import { useRef } from 'react';
import { View } from 'react-native';
import {
CircularTextScrubber,
type CircularTextScrubberRef,
} from './circular-text-scrubber';
const labels = {
instruction: 'Trace the circle, or use the buttons.',
empty: 'Add a reminder to begin.',
rotationEnd: 'Your reminder is at rest.',
clockwiseValue: '+30°',
counterclockwiseValue: '−30°',
};
export function Reminder() {
const ribbon = useRef<CircularTextScrubberRef>(null);
return (
<View style={{ width: 300, height: 400 }}>
<CircularTextScrubber
ref={ribbon}
text="Take a breath • Leave some room •"
separator="•"
centerTitle="A moment for you"
centerBody="Start here."
accessibilityLabel="Rotating reminder"
clockwiseLabel="Turn clockwise 30 degrees"
counterclockwiseLabel="Turn counterclockwise 30 degrees"
labels={labels}
colorScheme="dark"
radius={112}
onRotationEnd={(angle) => saveAngle(angle)}
/>
</View>
);
}saveAngle represents your application callback. To customize, pass palette={circularTextPalettes.light}, fontFamily="YourMediumFont", titleFontFamily="YourSerif", and monoFontFamily="YourMono". Load those font families before mounting. ribbon.current?.rotateBy(80), .stop(), and .reset() are event-handler calls, never render-time reads.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | Required | Full semantic phrase. Empty text displays labels.empty. |
separator | string | Required | Delimiter; appended when missing. Empty disables highlighting. Prefer one glyph. |
centerTitle, centerBody | string | Required | Stationary center copy. |
radius | number | Local fit | Preferred radius, clamped to the local slot. Nonfinite/nonpositive values use local fit. |
onRotationEnd | (angle: number) => void | — | Once per completed motion or active stop, normalized to [0, 360). Interrupted motion emits no completion. |
clockwiseLabel, counterclockwiseLabel | string | Required | Accessible button names. |
labels | object | Required | instruction, empty, rotationEnd (announcement), clockwiseValue, counterclockwiseValue (visible button text). |
accessibilityLabel | string | Required | Root description. |
colorScheme | 'light' | 'dark' | 'auto' | 'auto' | Complete system-aware theme. |
palette | CircularTextPalette | Theme palette | Overrides background, surface, text, muted, border, accent, success, error. |
fontFamily, titleFontFamily, monoFontFamily | string | System | Ring/body, center heading, controls. |
onInteractionStart | () => void | — | Cancel host automation before drag/button input. |
style | StyleProp<ViewStyle> | — | Root override; root has flex 1. Host owns safe areas. |
testID | string | 'circular-text-scrubber' | Stable -ring, -phrase, -clockwise, -counterclockwise suffixes. |
No controlled angle prop is exposed: the ref requests relative motion and callbacks report completion without duplicate events. rotateBy(NaN) and infinite deltas are ignored. stop() is idempotent while idle. reset() returns from the current pose over 300 ms. There are no exercise/session APIs, IDs to resolve, network requests, or internal autoplay.
Compatibility
Targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, Gesture Handler 3.3, and Skia 2.14.0. Supports iOS, Android, and web with Expo Go-compatible native dependencies; web needs the CanvasKit setup above.
No errors are reported in this piece.
Implementation notes
The ordinary phrase preserves reading order; the center switches to an ordinary flow layout when measured copy exceeds its circle; the component never creates an internal scroll region. The demo reserves the upper-right corner and places appearance controls below the piece. The accent separator starts 65 degrees before twelve, so the demo's 80-degree turn schedules the 360 ms light arc within its first three seconds. Light is a curved stroke and radial halo, with no rectangular overlay over type.
Icons in the optional demo: Lucide (ISC), copied from the local kit. Aurora uses the kit's sparkles glyph on a palette-matched tile. No generated assets.
Accessibility
Buttons have roles, explicit labels, and at least 48-point targets. The phrase is repeated in normal reading order below the decorative ring, which is hidden from screen readers. Completion announcements use supplied copy. Text scaling stays enabled.
Reduced motion, font scaling above 130%, slots smaller than 240 points, center copy that exceeds its measured circle, and phrases too long for one circumference use a non-scrolling flow layout with the center and phrase preserved. Rotation requests in this mode update the semantic angle with a 200 ms opacity pulse, without rotation or traveling light. The demo uses the available height instead of a fixed stage: at 320×694 it reduces heading spacing and omits secondary copy, while 390×844 keeps the full copy. Both keep the rotation and appearance controls on screen without a ScrollView. Below 620 points of local height (including 320×568 and landscape), or above 130% font scaling, only the full-screen outer container scrolls, with its vertical indicator hidden; there is no second safe area. The top-right corner remains clear. Parent layouts should provide at least the 48-point action row; a 300×400 slot is supported by the layout design.




