About this animation
A message bubble with animated delivery states and a retry lane, with configurable message content, labels, palette, and fonts.
Why this one
Sending is not reading. Separate clock, single check, double check, and alert indicators communicate pending, sent, delivered, read, and failure. Text and timestamp survive failure and retry. A pale light traces the lower edge and a soft accent halo flashes once per read attempt. Outgoing bubbles use the accent fill, and the 32 pt delivery icon sits below the bubble. Read uses the accent color. Retry is emitted immediately and at most once per attempt, independently of animation completion.
How it works
- Validate the controlled snapshot against the last accepted status for its attempt.
- Keep one message bubble in normal layout; change only the delivery footer.
- Fade the old footer completely, wait 20 ms, then replace its single text node.
- Derive icon drawing and label opacity from the reveal shared value on the UI thread.
- Request retries on touch in JS, locking by the pair of IDs before calling the host.
Install
npx expo install react-native-reanimated react-native-worklets @shopify/react-native-skia expo-linear-gradientExample with appearance knobs:
<MessageDeliveryMorph
ref={deliveryRef}
message={message}
onRetry={handleRetry}
labels={deliveryLabels}
accessibilityLabel="Outgoing message"
colorScheme="light"
motionScale={0.8}
showTimestamp={false}
style={{ maxWidth: 400 }}
/>Icons: Lucide (ISC), exact paths copied from the internal kit. All demo content is fictional. No reference assets are shipped.
Usage
import { MessageDeliveryMorph } from './message-delivery-morph';
<MessageDeliveryMorph
message={message}
onRetry={(messageId, attemptId) => requestNewAttempt(messageId, attemptId)}
labels={{
pending: 'Sending', sent: 'Sent', delivered: 'Delivered', read: 'Read',
failed: 'Couldn’t send', retry: 'Try again', retryRequested: 'Retry requested',
}}
accessibilityLabel="Your message"
/>The host owns message: { id, text, timestampLabel, attemptId, status, errorDescription? }. IDs must be nonempty and stable. Empty text and timestamp disappear from flow. Missing IDs disable retry safely. Preserve the original content and timestamp when creating a new attempt. Filter every remote response by both IDs before updating props: an obsolete attempt cannot be identified from a single controlled snapshot after the host has replaced it. The component never fabricates intermediate confirmations.
Props
| Prop | Meaning |
|---|---|
message | Required controlled message snapshot, as above. |
onRetry(messageId, attemptId) | Required request callback. Accept by supplying a new attempt ID with pending status; ignoring it keeps the request disabled. A second failure must have its own attempt ID. |
onStatusSettled?(messageId, status) | Visual completion only. Interrupted/outdated transitions do not call it. Never use it to send messages. |
labels | Required strings: pending = Sending; sent = Sent; delivered = Delivered; read = Read; failed = Couldn’t send; retry = Try again; retryRequested = Retry requested. Last string also announces retry. |
showTimestamp | Defaults to true; an empty timestamp has no reserved row. |
colorScheme | auto (default), dark, or light. |
palette | Complete override: background, surface, text, muted, border, accent, onAccent, success, error. Night and Paper defaults are built in. |
fontFamily, titleFontFamily, monoFontFamily | Body/status, recovery action, timestamp. System font fallback; fonts are supplied and loaded by the host. |
accessibilityLabel | Required region description. States use icons and text, never color alone. |
motionScale | 0–1.5, default 1; nonfinite values fall back to 1. Zero retains fades and semantics without light travel. |
style, testID | Host sizing and identifier prefix. Suffixes: -bubble, -status, -error, -retry. |
MessageDeliveryMorphRef exposes replayStatus() (current reveal only), requestRetry() (same deduplicated callback as touch), and reset() (visual reset only). Props always own remote state. No uncontrolled mode. Reset never clears the retry lock or calls business actions. Read celebration is once per ID/attempt, including across visual replay.
States and transitions
| Current | Accepted next status in the same attempt |
|---|---|
| pending | sent, delivered, read, failed |
| sent | delivered, read, failed |
| delivered | read |
| read | read only; late delivered ignored |
| failed | failed only; retry acceptance requires a new attempt |
A new ID or attempt starts a new controlled lifecycle. Confirmations cannot change text or timestamp within the same attempt. One status node exits for 90 ms, pauses for 20 ms, then enters; labels never crossfade. Reduced motion uses a static icon and 90 ms entry, with no traveling light or press displacement. Normal icon drawing takes 140 ms; label entry finishes at 110 ms. Read light lasts 360 ms.
Compatibility
Versions used: Expo 58, React Native 0.88.0, Reanimated 4.7.1, worklets 0.13.0, Skia 2.14.0, expo-linear-gradient ~58.0.3. Use Expo-compatible versions resolved by expo install. These packages are available in the matching Expo Go client. For web, configure CanvasKit using the installed Skia version's web setup and load Skia before mounting the component. The optional demo also uses expo-blur ~58.0.5. The host loads fonts once.
Accessibility
Root uses flex: 1, measures its own slot with onLayout, and adds no safe-area insets. The host sets maximum width and supplies enough height for its content. Bubble content and error descriptions stay in flow; no line truncation or disabled font scaling. The 48 pt status row places a compact retry action to the right on failure. The error description expands its measured height with an interruptible, explicitly massed spring; reduced motion settles immediately. Recovery takes space only on failure; it adds no empty description placeholder in successful states. Actions are at least 48 pt high. No gestures require precision or dragging. The retry target remains focusable after a new failed attempt; success announces the state and returns accessibility focus to the bubble without scrolling.
The Aurora demo is a conversation with fictional contact Mira Vale: a kit Lucide avatar, five prior incoming/outgoing messages on taller phones and three on shorter phones, the new outgoing message, a delivery footer, and a decorative composer preview. Incoming bubbles use elevated surfaces; outgoing bubbles use accent fills. Night/Paper and replay/failure controls sit below the composer. The header reserves 48 pt on the right for the viewer close control. The conversation aligns to the bottom and rises as the error description expands, above the separate composer. At 320×694, 320×568, and 390×844 the demo uses no ScrollView. Below 500 pt of available height, or with larger type, one outer scroll container without indicators keeps the full conversation and retry controls reachable. Compact layouts reduce spacing and omit the new message timestamp, preserving all three previous messages.
Demo delivery is simulated entirely in JS: 600 ms sent, 1050 ms delivered, 1450 ms read; 2900 ms new pending attempt, 3200 ms failure, 3800 ms retry request, 4250 ms sent; 5000 ms cycle. Real touch cancels scheduled autoplay and hands over control; Replay starts a fresh manual sequence. Theme and failure controls also take control. Timers and animations are cleaned up on unmount.
The rounded control band uses expo-blur at intensity 45 on iOS/web, with its own radius and clipping. Android uses an opaque surface fallback. The reusable component has no blur dependency. Skia must be initialized by the host before rendering on web, as with other Skia components.




