Primitives
Animated Number Avatar Badge Button Button Group Toggle Toggle Group Checkbox Chip Countdown Divider Fab Grid Icon Image Input Search Input Textarea Label Loader Marquee Masked Input Message Bubble Notification Banner Offline Banner Progress Progress Ring Radio Group Range Slider Rating Separator Skeleton Slider Stack Stepper Switch Text Theme Toggle Ticker Typing IndicatorComposed
Accordion Achievement Badge Action Bar Action Sheet Action Swap Activity Rings Add to Cart Button Address Card Agenda List Alert Amount Keypad Animated Icons App Bar Audio Message Audio Recorder Autocomplete Avatar Group Balance Display Bar List Barcode Before After Biometric Gate Bloom Menu Bottom Sheet Paywall Sheet Side Sheet Breadcrumb Calendar Calendar Strip Card Carousel Cart Item Character Charts Chat Composer Chat List Item Choice Card Circle Reveal Coach Marks Collapsible Collapsible Header Color Picker Combobox Command Palette Comment Thread Confetti Consent Sheet Context Menu Copy Field Country Picker Coupon Input Currency Input Data Table Date Range Picker Day Schedule Dialog Dock Donut Chart Drawer Driver Card Dropdown Menu Drum Picker Emoji Picker Empty State Error State Event Card Expandable Control Fader Feature Gate File Picker Filter Bar Follow Button Form Gauge Glass Glass Cluster Glass Dock Morph Glass Fab Morph Glass Lab Glass Orb Glass Search Morph Glass Sheet Edge Gradient Button Headline New Heatmap Image Cropper Image Viewer Kanban Card Keyboard Avoiding Keyboard Toolbar Leaderboard Level Progress Lightbox Like Button Line Chart List Reorderable List Swipeable Row Location Card Location Search Map Sheet Map View Markdown View Media Controls Media Picker Media Tile Mention Input Meter Bar Mini Player Mood Slider Morphing Modal Multi Select Navigation Rail Notification Item Number Input Onboarding Pager Order Card Order Summary OTP Input Pagination Paper Clip New Parallax Header Pass Card Password Strength Payment Button Payment Card Payment Method Permission Primer Phone Input PIN Pad Polaroid New Poll Popover Post Card Postage Stamp New Price Tag Pricing Table Product Card Product Gallery Profile Header Promo Banner Prompt Input Property Card Pull To Refresh QR Code Quick Actions Quick Replies Rating Summary Reaction Picker Resend Timer Review Card Review Prompt Ride Option Save Button Scan Frame Scroll Spy Tabs Seat Map Section Header Section Index Segmented Control Select Share Button Share Sheet Shutter Button Signature Pad Slide to Confirm Slot Picker Smart Banner Social Login Button Sparkline Splash Screen Split Button Stat Card Stateful Button Status Dot Step Flow Steps Sticker New Stopwatch Stories Progress Stories Viewer Story Ring Streak Counter Streaming Text Success Seal Swipe Deck Tabs Tag Input Tape New Text Reveal Thinking Indicator Time Picker Timeline Timer Ring Tip Selector Toast Toolbar Tooltip Track Row Transaction Row Trip Progress Variant Picker Video Controls Waveform Weather Card Weekday Picker Zoom TransitionScreens
Account Switcher Biometric Setup Delete Account Forgot Password Grow Auth Login Login — Dark Stage Login — Dense Work Login — Editorial Login — Illustrated Login — Minimal Ink Login — Social Login — Video Loop Food Onboarding Onboarding Onboarding Dark Objects Onboarding Editorial Onboarding Illustrated Onboarding Personalize Onboarding Photo Carousel OTP Verification Passkey — Create Password Reset Permission — Notifications Phone Verification Onboarding Quiz Session Devices Signup Sign-up Chat Sign-up Photo Sheet Social Auth Split Auth Verify Identity Welcome AI Chat AI Assistant Channels List Chat Info Chat List Chat Media Chat Thread Create Group Group Info Message Requests New Message Kept Messages Accessibility Activity Log Appearance Authenticator Blocked Accounts Export Your Data Downloads Edit Profile Edit Profile — Rows Emergency Call Empty States Set Error State Send Feedback Device Locator Help Center Home — Bento Home — Dark Home — Editorial Home Feed Invite Friends Language & Region Linked Accounts Not Found (404) Notification Settings Notifications Password Vault Paywall Paywall — Comparison Paywall — Dark Stage Paywall — Editorial Paywall — Photo Paywall — Trial Timeline Privacy Profile Profile — Gamified Profile — Minimal Profile — Photo Hero QR Scanner Report Issue Saved Items Phone Time Search Search — Command Palette Search — Visual Account Health Settings Settings — Dark Device Settings — Search Storage & Data Terms & Privacy Dating Chat Dating Filters Profile Discover Its A Match Into You Matches Profile Detail Profile Editor Courier Matching Delivery History Delivery Support Package Schedule Parcel Tracking Proof Of Delivery Quick Aisle Quick Home Send Package Substitutions Add Payment Address Book Cart Categories Checkout Checkout — Steps Checkout Success Delivery Filters & Sort Flash Sale Gift Cards Loyalty Points Order Cancel Order Detail Orders Pay Later Checkout Payment Methods Product Detail Product Detail — Hero Product Grid Product Reviews Promo Codes Recently Viewed Return Items Search Results Shop Assistant Store Home Tip & Rate Courier Order Tracking Wishlist Write a Review Event Checkin Event Hero Events Discover My Tickets Seat Selection Ticket Checkout Add Card Bank Accounts Dashboard — Bento Bill Pay Budgets Card Details Crypto Portfolio Finance Dashboard Dashboard — Dark Exchange Currency Expense List Invoice Detail Manage Cards Plan Management Savings Goals Send Money Spending Analytics Split Bill Subscriptions Token Detail Token Swap Transactions Wallet Home Wallet Movers Courier Tracking Food Cart Food Home Food Orders Grocery List Recipe Detail Restaurant — Dark Restaurant Detail Activity Rings Fitness Home Habit Quest Habit Tracker Meal Planner Med Store Meditation Player Mood Check-in Nutrition Home Period Tracker Prior Auth Prior Auth Cases Quest Complete Run Tracking Rx Scan Sleep Tracker Telehealth Booking Walk Map Water Tracker Workout Complete Workout Detail Workout Session Applications Apply Company Interview Schedule Job Detail Job Feed Resume Saved Jobs Create Listing Item Chat Listing Detail Make Offer Market Checkout Market Home Market Search My Listings Offers Inbox Seller Profile Audiobook Player Camera Gallery Albums Game Store Mini Player Music Player Photo Editor Photo Gallery Playlist Detail Podcast Player Clips Feed Serene Home Sound Mixer Stories Video Call Video Player Article Reader Course Detail Event Detail Memory Onboarding Icon Lab Leaderboard Lesson Player Match Detail News Editorial News Home Plant Care Prediction Live Prediction Welcome Smart Home Standings Ticket Wallet Topics Follow Weather Detail Add Task Sheet Alarm Clock Calendar Day Calendar Month Doc Scanner File Manager Focus Timer Journal Kanban Board Mail Inbox Mail Thread Note Editor Notes List Reminders Task List Time Blocks Agent Profile Compare Homes Mortgage Calculator Property Detail Rental Application Saved Homes Schedule Tour Search List Search Map Complete Destination Driver Arriving Ev Charging History Home In Trip Matching Options Parking Receipt Safety Schedule Scooter Unlock Booking Confirmed Booking Review Booking Slots My Bookings Pro On The Way Provider Detail Provider List Rate Service Services Home Arc Create Arc Detail Arc Onboarding Avatar Creator Comments Sheet Create Post Explore Grid Followers List Hashtag Feed Mentions Post Detail Post Hero Social Profile Boarding Pass Booking Confirm Car Rental Trip Discover Flight Live Flight Preferences Flight Results Flight Search Hotel Detail Live Transit Trip — Map Day Trip — Rideshare Request Seat Select Travel Home Trip Itinerary Trip OnboardingNothing matches — try another word.
Typing Indicator
Chat typing indicator with three dots that brighten in turn. When the reply arrives, the bubble grows to fit it and the words appear in reading order.
Preview
import * as React from "react";
import { forwardRef, useEffect, useState } from "react";
import { View, type LayoutChangeEvent, type ViewProps } from "react-native";
import Animated, {
Easing,
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSpring,
withTiming,
type SharedValue,
} from "react-native-reanimated";
import { cn, ease, spring, timing, useReducedMotion, useTheme } from "@lumioui/core";
/**
* TypingIndicator — someone is writing. The dots never hop: they sit still
* and brighten in turn, in place, each 70ms after the last, once per ~1.2s
* (the Button's busy-dots pattern — no band is drawn across them).
* <TypingIndicator /> — bare dots
* <TypingIndicator bubble /> — dots in a received-message bubble
* <TypingIndicator visible={x} /> — switches on / dims out in place
* <TypingIndicator bubble message={reply} /> — the bubble becomes the reply
*
* With `message` the bubble springs to the measured size of the reply while
* the dots dim toward its leading edge, then the words ignite in reading
* order (~50ms per word, each over 160ms with a 4pt rise). Typing that stops
* without a message dims the dots, then the bubble. Reduced motion keeps the
* light and drops the size spring, rise and scale.
* Expo Go compatible.
*/
export interface TypingIndicatorProps extends Omit<ViewProps, "className"> {
/** Show the dots. Switches on / dims out rather than hard-mounting. Default true. */
visible?: boolean;
/** Wrap the dots in a received-message bubble surface. Default false. */
bubble?: boolean;
/** Dot diameter in px. Default 8. */
dotSize?: number;
/** Multiplier on the ~1.2s light sweep. Default 1. */
speed?: number;
/** Dot color token. Default "mutedForeground". */
color?: "primary" | "foreground" | "mutedForeground";
/** The reply that ends the typing — the bubble grows into it, words igniting in order. */
message?: string;
/** Widest the reply text may wrap at, in pt. Default 260. */
maxWidth?: number;
/** Fires once the last word of `message` has ignited. */
onRevealed?: () => void;
/** Screen-reader announcement while typing. Default "Typing…". */
accessibilityLabel?: string;
className?: string;
}
/** One sweep of light across the dots, then a rest. */
const SWEEP_MS = 1200;
/** Each dot brightens this long after the one before it. */
const DOT_STEP = 70;
/** How long a dot stays lit as the band passes (rise + fall). */
const DOT_LIT = 420;
/** Dots rest at this brightness between passes. */
const DOT_REST = 0.35;
/** Words ignite on the AI-reply cadence (§5a): ~50ms apart, 160ms each, 4pt rise. */
const WORD_GAP = 50;
const WORD_RISE = 4;
/** The bubble's own padding when `bubble` is set (px-4 / py-2). */
const PAD_X = 16;
const PAD_Y = 8;
/** Dimmed bubble settles to this scale as it leaves. */
const EXIT_SCALE = 0.97;
const IGNITE = { ...timing.fast, easing: Easing.bezier(...ease.out) };
const DIM = { ...timing.recede, easing: Easing.bezier(...ease.in) };
export const TypingIndicator = forwardRef<React.ElementRef<typeof View>, TypingIndicatorProps>(
function TypingIndicator(
{
visible = true,
bubble = false,
dotSize = 8,
speed = 1,
color = "mutedForeground",
message,
maxWidth = 260,
onRevealed,
accessibilityLabel = "Typing…",
className,
...props
},
ref
) {
const { colors } = useTheme();
const reduce = useReducedMotion();
const words = React.useMemo(() => (message ? message.split(/\s+/).filter(Boolean) : []), [message]);
const replying = words.length > 0;
// Sizes: the dot bubble as it stands, and the reply's measured text block.
const [from, setFrom] = useState({ w: 0, h: 0 });
const [text, setText] = useState({ w: 0, h: 0 });
const padX = bubble ? PAD_X : 0;
const padY = bubble ? PAD_Y : 0;
const ready = replying && from.w > 0 && text.w > 0;
// Container first, then content: the bubble ignites, the dots switch on
// 120ms later. Leaving runs the other way round.
const shell = useSharedValue(0);
const dots = useSharedValue(0);
const grow = useSharedValue(0);
// The callback rides a ref — a new identity must not replay the reveal.
const revealed = React.useRef(onRevealed);
revealed.current = onRevealed;
useEffect(() => {
if (replying) return;
if (visible) {
shell.value = withTiming(1, IGNITE);
dots.value = withDelay(timing.recede.duration, withTiming(1, IGNITE));
} else {
dots.value = withTiming(0, DIM);
shell.value = withDelay(timing.recede.duration, withTiming(0, DIM));
}
}, [visible, replying, shell, dots]);
// The reply: dots dim toward the leading edge while the bubble springs
// to the reply's size; words follow once the dots are out.
useEffect(() => {
if (!ready) return;
shell.value = withTiming(1, IGNITE);
dots.value = withTiming(0, DIM);
grow.value = reduce ? 1 : withSpring(1, spring.swap);
const total = timing.recede.duration + (words.length - 1) * WORD_GAP + timing.fast.duration;
const id = setTimeout(() => revealed.current?.(), total);
return () => clearTimeout(id);
}, [ready, words.length, reduce, shell, dots, grow]);
const shellStyle = useAnimatedStyle(() => {
const base = {
opacity: shell.value,
transform: [{ scale: reduce ? 1 : EXIT_SCALE + (1 - EXIT_SCALE) * shell.value }],
};
if (!ready) return base;
const w = text.w + padX * 2;
const h = text.h + padY * 2;
return {
...base,
width: from.w + (w - from.w) * grow.value,
height: from.h + (h - from.h) * grow.value,
};
});
const dotsStyle = useAnimatedStyle(() => ({
opacity: dots.value,
transform: [{ translateX: replying && !reduce ? (dots.value - 1) * WORD_RISE : 0 }],
}));
const onShellLayout = (e: LayoutChangeEvent) => {
if (replying && from.w > 0) return;
const { width, height } = e.nativeEvent.layout;
if (width !== from.w || height !== from.h) setFrom({ w: width, h: height });
};
return (
<Animated.View
ref={ref}
style={shellStyle}
onLayout={onShellLayout}
accessibilityRole={replying ? "text" : "progressbar"}
accessibilityLabel={replying ? message : accessibilityLabel}
accessibilityState={replying ? undefined : { busy: visible }}
accessibilityLiveRegion={replying ? "polite" : "none"}
className={cn(
// 40pt-tall received bubble, same radius family as message bubbles.
bubble && "h-10 self-start justify-center overflow-hidden rounded-3xl rounded-bl-md bg-secondary px-4",
className
)}
{...props}
>
<Animated.View className="flex-row items-center" style={[{ gap: dotSize * 0.7 }, dotsStyle]}>
{[0, 1, 2].map((i) => (
<Dot
key={i}
index={i}
size={dotSize}
speed={speed}
color={colors[color]}
active={visible && !replying}
/>
))}
</Animated.View>
{replying ? (
<>
{/* Hidden twin of the reply at its final wrap — the size the bubble grows to. */}
<View
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={{ position: "absolute", left: 0, top: 0, width: maxWidth, opacity: 0 }}
>
<View
className="flex-row flex-wrap self-start"
onLayout={(e) => {
const { width, height } = e.nativeEvent.layout;
if (width !== text.w || height !== text.h) setText({ w: width, h: height });
}}
>
{words.map((w, i) => (
<Animated.Text key={i} className="text-base leading-6 text-foreground">
{i < words.length - 1 ? `${w} ` : w}
</Animated.Text>
))}
</View>
</View>
{ready ? (
<View
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
className="flex-row flex-wrap"
style={{ position: "absolute", left: padX, top: padY, width: text.w }}
>
{words.map((w, i) => (
<Word key={i} delay={timing.recede.duration + i * WORD_GAP} reduce={reduce}>
{i < words.length - 1 ? `${w} ` : w}
</Word>
))}
</View>
) : null}
</>
) : null}
</Animated.View>
);
}
);
TypingIndicator.displayName = "TypingIndicator";
interface DotProps {
index: number;
size: number;
speed: number;
color: string;
/** The sweep only runs while someone is typing — a hidden or answered
* indicator costs no UI-thread frames. */
active: boolean;
}
function Dot({ index, size, speed, color, active }: DotProps) {
const clock = useSharedValue(0);
const cycle = SWEEP_MS * speed;
const peak = (DOT_LIT / 2 + index * DOT_STEP) * speed;
const half = (DOT_LIT / 2) * speed;
useEffect(() => {
if (!active) {
cancelAnimation(clock);
return;
}
clock.value = 0;
clock.value = withRepeat(withTiming(1, { duration: cycle, easing: Easing.linear }), -1, false);
return () => cancelAnimation(clock);
}, [active, cycle, clock]);
const style = useAnimatedStyle(() => ({ opacity: lightAt(clock, cycle, peak, half) }));
return (
<Animated.View
style={[{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }, style]}
/>
);
}
/** Brightness of a dot as the band passes: a soft cosine bump around its peak time. */
function lightAt(clock: SharedValue<number>, cycle: number, peak: number, half: number): number {
"worklet";
const x = (clock.value * cycle - peak) / half;
const b = Math.abs(x) < 1 ? 0.5 * (1 + Math.cos(Math.PI * x)) : 0;
return DOT_REST + (1 - DOT_REST) * b;
}
/** One word of the reply — ignites with a 4pt rise after `delay`. */
function Word({ delay, reduce, children }: { delay: number; reduce: boolean; children: string }) {
const on = useSharedValue(0);
useEffect(() => {
on.value = withDelay(delay, withTiming(1, IGNITE));
}, [delay, on]);
const style = useAnimatedStyle(() => ({
opacity: on.value,
transform: [{ translateY: reduce ? 0 : (1 - on.value) * WORD_RISE }],
}));
return (
<Animated.Text style={style} className="text-base leading-6 text-foreground">
{children}
</Animated.Text>
);
} import { TypingIndicator } from "@/components/ui/typing-indicator/typing-indicator";
export function Example() {
return (
<TypingIndicator />
);
} Installation
Install the dependencies
npx expo install react-native-reanimatedCopy the source from the Code tab into your project
components/ui/typing-indicator/typing-indicator.tsxcomponents/ui/typing-indicator/index.tsUpdate the import paths to match your project setup.
Usage
import { TypingIndicator } from "@/components/ui/typing-indicator/typing-indicator"; <TypingIndicator /> Examples
A DM about a weekend climb.
import * as React from "react";
import { View } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Accent, timing } from "@lumioui/core";
import { TypingIndicator } from "../typing-indicator";
import { Avatar } from "../../avatar/avatar";
import { Button } from "../../button/button";
import { Icon } from "../../icon/icon";
import { Text } from "../../text/text";
/** A message; `typed` ones arrive through the typing bubble, `arrived` once the reply lands. */
type Msg = { id: number; from: "them" | "me"; text: string; typed?: boolean; arrived?: boolean };
/** Scripted exchange — each quick reply gets a typed answer back. */
const SCRIPT: { reply: string; answer: string }[] = [
{ reply: "Still on for 7 AM?", answer: "Yes! I'll bring the 60 m rope and the quickdraws." },
{ reply: "I'll grab coffee on the way", answer: "Oat flat white please. Parking lot by the trailhead?" },
{ reply: "See you there", answer: "Perfect. Lot B at 6:50, look for the green van." },
];
/** Demo pacing — the opening typing bubble holds just long enough to read the light sweep. */
const OPENING_TYPING_MS = 2400;
/** How long Callum types an answer to each later reply. */
const ANSWER_TYPING_MS = 2200;
const AVATAR = { uri: "https://i.pravatar.cc/150?img=11" };
/** TypingThread — a DM about a weekend climb. Send the suggested reply and
* Callum's typing bubble appears, then grows into his answer word by word. */
export function TypingThread() {
// Opens mid-conversation: the first reply is already sent and Callum is
// typing, so the indicator is on screen at rest from the first frame.
const [msgs, setMsgs] = React.useState<Msg[]>([
{ id: 0, from: "them", text: "Forecast for the Lumio Crags looks dry on Saturday" },
{ id: 1, from: "me", text: SCRIPT[0]!.reply },
{ id: 2, from: "them", text: SCRIPT[0]!.answer, typed: true },
]);
const [step, setStep] = React.useState(0);
const [rowW, setRowW] = React.useState(320);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const typing = msgs.some((m) => m.typed && !m.arrived);
const arrive = React.useCallback((id: number, i: number, delay: number) => {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
setMsgs((all) => all.map((m) => (m.id === id ? { ...m, arrived: true } : m)));
setStep(i + 1);
}, delay);
}, []);
React.useEffect(() => {
arrive(2, 0, OPENING_TYPING_MS);
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, [arrive]);
const next = SCRIPT[step];
const send = () => {
if (!next || typing) return;
const id = msgs.length;
setMsgs((m) => [
...m,
{ id, from: "me", text: next.reply },
{ id: id + 1, from: "them", text: next.answer, typed: true },
]);
arrive(id + 1, step, ANSWER_TYPING_MS);
};
// The avatar sits beside the bottom-most message from Callum.
const lastThem = [...msgs].reverse().find((m) => m.from === "them")?.id;
return (
<Accent color="green" className="w-full">
<View className="w-full overflow-hidden rounded-3xl bg-card">
<View className="flex-row items-center gap-3 border-b border-border px-4 py-3">
<Icon name="chevron-left" size="md" color="primaryText" accessibilityLabel="Back" />
<Avatar size="md" source={AVATAR} fallback="CR" alt="Callum Reid" />
<View className="flex-1">
<Text className="text-base font-semibold">Callum Reid</Text>
{/* Presence swaps in place: the old line dims while the new one ignites. */}
<View className="h-5">
<Animated.View
key={typing ? "typing" : "active"}
entering={FadeIn.duration(timing.fast.duration)}
exiting={FadeOut.duration(timing.recede.duration)}
className="absolute left-0 top-0"
>
<Text variant="muted">{typing ? "typing…" : "Active now"}</Text>
</Animated.View>
</View>
</View>
<Icon name="phone" size="md" color="primaryText" accessibilityLabel="Call" />
</View>
<View
className="min-h-[300px] justify-end gap-2 px-4 py-4"
onLayout={(e) => setRowW(e.nativeEvent.layout.width - 32)}
>
{msgs.map((m) =>
m.from === "them" ? (
<View key={m.id} className="w-full flex-row items-end gap-2">
{m.id === lastThem ? (
<Avatar size="sm" source={AVATAR} fallback="CR" alt="Callum Reid" />
) : (
<View className="h-8 w-8" />
)}
{m.typed ? (
<TypingIndicator
bubble
message={m.arrived ? m.text : undefined}
maxWidth={Math.round(rowW * 0.8) - 32}
accessibilityLabel="Callum is typing"
/>
) : (
<View className="max-w-[80%] rounded-3xl rounded-bl-md bg-secondary px-4 py-2">
<Text className="text-base leading-6">{m.text}</Text>
</View>
)}
</View>
) : (
<View key={m.id} className="max-w-[80%] self-end rounded-3xl rounded-br-md bg-primary px-4 py-2">
<Text className="text-base leading-6 text-primary-foreground">{m.text}</Text>
</View>
)
)}
</View>
<View className="border-t border-border p-3">
{next ? (
<Button variant="secondary" onPress={send} disabled={typing}>
{`Reply "${next.reply}"`}
</Button>
) : (
<Text variant="muted" className="py-2 text-center">Seen just now</Text>
)}
</View>
</View>
</Accent>
);
} API reference
visible boolean Show the dots. Switches on / dims out rather than hard-mounting. Default true.
true bubble boolean Wrap the dots in a received-message bubble surface. Default false.
false dotSize number Dot diameter in px. Default 8.
8 speed number Multiplier on the ~1.2s light sweep. Default 1.
1 color "primary" | "foreground" | "mutedForeground" Dot color token. Default "mutedForeground".
"mutedForeground" message string The reply that ends the typing — the bubble grows into it, words igniting in order.
—maxWidth number Widest the reply text may wrap at, in pt. Default 260.
260 onRevealed () => void Fires once the last word of message has ignited.
accessibilityLabel string Screen-reader announcement while typing. Default "Typing…".
"Typing…" className string —