Dot Loader
Loading dots that hop in a wave, breathe in a pulse, or sit in a chat bubble as a typing indicator.
import { useEffect } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type DotLoaderVariant = 'wave' | 'pulse' | 'typing';
export type DotLoaderProps = {
/** `wave` hops, `pulse` breathes, `typing` sits in a chat bubble. Defaults to `wave`. */
variant?: DotLoaderVariant;
/** Number of dots. Defaults to 3. */
count?: number;
/** Dot diameter. Defaults to 8. */
size?: number;
/** Dot colour. Defaults to the theme's muted text colour. */
color?: string;
/** One cycle, in ms. Defaults to 1100. */
period?: number;
/** Read by screen readers. Defaults to 'Loading'. */
accessibilityLabel?: string;
testID?: string;
style?: StyleProp<ViewStyle>;
};
/**
* Dots that say something is on its way. In a wave they hop one after
* another, in a pulse they swell and brighten, and as typing they sit in a
* chat bubble. One clock drives every dot.
*/
export function DotLoader({
variant = 'wave',
count = 3,
size = 8,
color,
period = 1100,
accessibilityLabel = 'Loading',
testID,
style,
}: DotLoaderProps) {
const { colors, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const clock = useSharedValue(0);
const ink = color ?? colors.textMuted;
useEffect(() => {
clock.set(0);
clock.set(withRepeat(withTiming(1, { duration: period, easing: Easing.linear }), -1, false));
return () => cancelAnimation(clock);
}, [period, clock]);
const dots = (
<View
style={[
styles.row,
{ gap: size * 0.75, height: size * (variant === 'wave' && !reduced ? 2.2 : 1.6) },
]}
>
{Array.from({ length: count }, (_, i) => (
<Dot
key={i}
index={i}
count={count}
size={size}
color={ink}
clock={clock}
variant={variant}
still={reduced}
/>
))}
</View>
);
return (
<View
testID={testID}
accessible
accessibilityRole="progressbar"
accessibilityLabel={accessibilityLabel}
style={style}
>
{variant === 'typing' ? (
<View
style={[
styles.bubble,
{
backgroundColor: colors.surfaceRaised,
borderRadius: radius.lg,
borderBottomLeftRadius: radius.xs,
paddingHorizontal: size * 1.6,
paddingVertical: size * 1.1,
},
]}
>
{dots}
</View>
) : (
dots
)}
</View>
);
}
type DotProps = {
index: number;
count: number;
size: number;
color: string;
clock: SharedValue<number>;
variant: DotLoaderVariant;
/** Reduced motion: dots brighten in turn but do not move. */
still: boolean;
};
function Dot({ index, count, size, color, clock, variant, still }: DotProps) {
const style = useAnimatedStyle(() => {
// Each dot runs a little behind the one before; the last third of the cycle is a rest.
const phase = (clock.get() - index * (0.6 / Math.max(1, count)) + 1) % 1;
const bump = phase < 0.6 ? Math.sin((phase / 0.6) * Math.PI) : 0;
if (still) return { opacity: 0.35 + bump * 0.65, transform: [] };
if (variant === 'pulse') {
return { opacity: 0.35 + bump * 0.65, transform: [{ scale: 0.7 + bump * 0.45 }] };
}
const hop = variant === 'typing' ? 0.45 : 0.75;
return {
opacity: 0.45 + bump * 0.55,
transform: [{ translateY: -bump * size * hop }],
};
});
return (
<Animated.View
style={[{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }, style]}
/>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'center' },
bubble: { alignSelf: 'flex-start' },
});Installation
npx kinetik-ui add dot-loaderpnpm dlx kinetik-ui add dot-loaderbunx kinetik-ui add dot-loaderyarn dlx kinetik-ui add dot-loaderThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/dot-loader.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/dot-loader.tsx
Usage
import { DotLoader } from '@/components/kinetik/dot-loader';
<DotLoader />
<DotLoader variant="pulse" color={theme.colors.azure} />
<DotLoader variant="typing" accessibilityLabel="Mara is typing" />When to use
- Short waits inside a piece of the screen: a button, a card, a reply on its way.
- Chat threads, with
variant="typing".
When not to use
- Waits with a known length. Show progress.
- Loading a whole screen of content. Use Skeleton Sweep, which shows the shape of what is coming.
Notes
- One shared value drives every dot on the UI thread; the JS thread is never involved.
- Each cycle ends with a short rest, so the rhythm reads as calm rather than busy.
- It keeps animating while mounted. Unmount it as soon as the wait is over.
- With reduced motion the dots stay put and brighten in turn.
Props
DotLoaderProps
| Prop | Type | Default | Description |
|---|---|---|---|
variant | DotLoaderVariant | 'wave' | `wave` hops, `pulse` breathes, `typing` sits in a chat bubble. Defaults to `wave`. |
count | number | 3 | Number of dots. Defaults to 3. |
size | number | 8 | Dot diameter. Defaults to 8. |
color | string | — | Dot colour. Defaults to the theme's muted text colour. |
period | number | 1100 | One cycle, in ms. Defaults to 1100. |
accessibilityLabel | string | 'Loading' | Read by screen readers. Defaults to 'Loading'. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One repeating shared value drives every dot on the UI thread. It animates for as long as it is shown, so unmount it when loading ends.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.10× the system Settings app on the same emulator (indicative only) · keeps drawing at rest · 152 MB app memory. Animating at rest is the loader itself; unmount it when loading ends.
Real-device measurement pending.
Accessibility
- Reduced motion
- The dots stay in place and only brighten one after another.
- Screen readers
- One progress indicator named by accessibilityLabel (Loading by default), such as "The host is typing".
- Touch target
- Not interactive.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/dot-loader.tsx
import { useEffect } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type DotLoaderVariant = 'wave' | 'pulse' | 'typing';
export type DotLoaderProps = {
/** `wave` hops, `pulse` breathes, `typing` sits in a chat bubble. Defaults to `wave`. */
variant?: DotLoaderVariant;
/** Number of dots. Defaults to 3. */
count?: number;
/** Dot diameter. Defaults to 8. */
size?: number;
/** Dot colour. Defaults to the theme's muted text colour. */
color?: string;
/** One cycle, in ms. Defaults to 1100. */
period?: number;
/** Read by screen readers. Defaults to 'Loading'. */
accessibilityLabel?: string;
testID?: string;
style?: StyleProp<ViewStyle>;
};
/**
* Dots that say something is on its way. In a wave they hop one after
* another, in a pulse they swell and brighten, and as typing they sit in a
* chat bubble. One clock drives every dot.
*/
export function DotLoader({
variant = 'wave',
count = 3,
size = 8,
color,
period = 1100,
accessibilityLabel = 'Loading',
testID,
style,
}: DotLoaderProps) {
const { colors, radius } = useKinetikTheme();
const reduced = useReduceMotion();
const clock = useSharedValue(0);
const ink = color ?? colors.textMuted;
useEffect(() => {
clock.set(0);
clock.set(withRepeat(withTiming(1, { duration: period, easing: Easing.linear }), -1, false));
return () => cancelAnimation(clock);
}, [period, clock]);
const dots = (
<View
style={[
styles.row,
{ gap: size * 0.75, height: size * (variant === 'wave' && !reduced ? 2.2 : 1.6) },
]}
>
{Array.from({ length: count }, (_, i) => (
<Dot
key={i}
index={i}
count={count}
size={size}
color={ink}
clock={clock}
variant={variant}
still={reduced}
/>
))}
</View>
);
return (
<View
testID={testID}
accessible
accessibilityRole="progressbar"
accessibilityLabel={accessibilityLabel}
style={style}
>
{variant === 'typing' ? (
<View
style={[
styles.bubble,
{
backgroundColor: colors.surfaceRaised,
borderRadius: radius.lg,
borderBottomLeftRadius: radius.xs,
paddingHorizontal: size * 1.6,
paddingVertical: size * 1.1,
},
]}
>
{dots}
</View>
) : (
dots
)}
</View>
);
}
type DotProps = {
index: number;
count: number;
size: number;
color: string;
clock: SharedValue<number>;
variant: DotLoaderVariant;
/** Reduced motion: dots brighten in turn but do not move. */
still: boolean;
};
function Dot({ index, count, size, color, clock, variant, still }: DotProps) {
const style = useAnimatedStyle(() => {
// Each dot runs a little behind the one before; the last third of the cycle is a rest.
const phase = (clock.get() - index * (0.6 / Math.max(1, count)) + 1) % 1;
const bump = phase < 0.6 ? Math.sin((phase / 0.6) * Math.PI) : 0;
if (still) return { opacity: 0.35 + bump * 0.65, transform: [] };
if (variant === 'pulse') {
return { opacity: 0.35 + bump * 0.65, transform: [{ scale: 0.7 + bump * 0.45 }] };
}
const hop = variant === 'typing' ? 0.45 : 0.75;
return {
opacity: 0.45 + bump * 0.55,
transform: [{ translateY: -bump * size * hop }],
};
});
return (
<Animated.View
style={[{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }, style]}
/>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'center' },
bubble: { alignSelf: 'flex-start' },
});