Empty State
A calm empty screen: three cards rise and fan out, a blue dot lands, and the next step settles in beneath.
import { useEffect, type ReactNode } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type EmptyStateAction = { label: string; onPress: () => void };
export type EmptyStateProps = {
title: string;
description?: string;
/** Drawn on the front card of the illustration, e.g. an inbox or search icon. */
icon?: ReactNode;
/** The next step, as a solid button. */
action?: EmptyStateAction;
/** A quieter alternative, as a text button. */
secondaryAction?: EmptyStateAction;
/** Replays the entrance when it changes, e.g. the current filter. */
playKey?: string | number;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const CARD_W = 112;
const CARD_H = 84;
/**
* A calm empty screen with somewhere to go next. Three cards rise and fan
* out one after another, a blue dot lands on the front one, and the text and
* actions settle in beneath. It plays once, then stays still.
*/
export function EmptyState({
title,
description,
icon,
action,
secondaryAction,
playKey,
testID,
style,
}: EmptyStateProps) {
const { colors, font, radius, space, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const back = useSharedValue(0);
const middle = useSharedValue(0);
const front = useSharedValue(0);
const dot = useSharedValue(0);
const text = useSharedValue(0);
useEffect(() => {
const cards = [back, middle, front];
if (reduced) {
[...cards, dot, text].forEach((v) => v.set(1));
return;
}
[...cards, dot, text].forEach((v) => v.set(0));
cards.forEach((v, i) => v.set(withDelay(i * 90, withSpring(1, spring('smooth')))));
dot.set(withDelay(420, withSpring(1, spring('bouncy'))));
text.set(withDelay(200, withSpring(1, spring('smooth'))));
}, [reduced, playKey, back, middle, front, dot, text]);
const textStyle = useAnimatedStyle(() => ({
opacity: text.get(),
transform: [{ translateY: (1 - text.get()) * 10 }],
}));
const dotStyle = useAnimatedStyle(() => ({
opacity: Math.min(1, dot.get() * 2),
transform: [{ scale: dot.get() }],
}));
return (
<View testID={testID} style={[styles.root, { gap: space[5] }, style]}>
<View
style={styles.art}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{/* Back to front: the two behind turn away from the middle as they rise. */}
<Card
t={back}
angle={-9}
dx={-22}
background={colors.surface}
border={colors.border}
radius={radius.lg}
/>
<Card
t={middle}
angle={8}
dx={22}
background={colors.surface}
border={colors.border}
radius={radius.lg}
/>
<Card
t={front}
angle={0}
dx={0}
background={colors.surfaceRaised}
border={colors.borderStrong}
radius={radius.lg}
lift={10}
shadowStyle={shadow.md}
>
<View
style={[styles.tile, { backgroundColor: colors.azureSoft, borderRadius: radius.sm }]}
>
{icon}
</View>
<View style={{ flex: 1, gap: 6 }}>
<View style={[styles.bar, { width: '80%', backgroundColor: colors.track }]} />
<View style={[styles.bar, { width: '50%', backgroundColor: colors.track }]} />
</View>
<Animated.View
style={[
styles.dot,
{ backgroundColor: colors.azure, borderColor: colors.surfaceRaised },
dotStyle,
]}
/>
</Card>
</View>
<Animated.View style={[styles.copy, { gap: space[2] }, textStyle]}>
<Text
accessibilityRole="header"
style={[
styles.center,
{ color: colors.text, fontSize: font.size.xl, fontWeight: font.weight.semibold },
]}
>
{title}
</Text>
{description ? (
<Text
style={[
styles.center,
{ color: colors.textMuted, fontSize: font.size.md, lineHeight: 22 },
]}
>
{description}
</Text>
) : null}
{action || secondaryAction ? (
<View style={[styles.actions, { marginTop: space[3], gap: space[1] }]}>
{action ? (
<Pressable
onPress={action.onPress}
accessibilityRole="button"
style={({ pressed }) => [
styles.primary,
{
backgroundColor: colors.ember,
borderRadius: radius.pill,
transform: [{ scale: pressed && !reduced ? 0.97 : 1 }],
},
]}
>
<Text
style={{
color: colors.onEmber,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
}}
>
{action.label}
</Text>
</Pressable>
) : null}
{secondaryAction ? (
<Pressable
onPress={secondaryAction.onPress}
accessibilityRole="button"
style={({ pressed }) => [styles.secondary, { opacity: pressed ? 0.6 : 1 }]}
>
<Text
style={{
color: colors.textMuted,
fontSize: font.size.sm,
fontWeight: font.weight.semibold,
}}
>
{secondaryAction.label}
</Text>
</Pressable>
) : null}
</View>
) : null}
</Animated.View>
</View>
);
}
type CardProps = {
t: SharedValue<number>;
angle: number;
dx: number;
background: string;
border: string;
radius: number;
lift?: number;
shadowStyle?: string;
children?: ReactNode;
};
function Card({
t,
angle,
dx,
background,
border,
radius,
lift = 0,
shadowStyle,
children,
}: CardProps) {
const style = useAnimatedStyle(() => {
const p = t.get();
return {
opacity: Math.min(1, p * 1.5),
transform: [
{ translateX: dx * p },
{ translateY: (1 - p) * 26 - lift * p },
{ rotate: `${angle * p}deg` },
{ scale: 0.9 + p * 0.1 },
],
};
});
return (
<Animated.View
style={[
styles.card,
{
backgroundColor: background,
borderColor: border,
borderRadius: radius,
boxShadow: shadowStyle,
},
style,
]}
>
{children}
</Animated.View>
);
}
const styles = StyleSheet.create({
root: { alignItems: 'center', paddingVertical: 8 },
art: {
width: CARD_W + 60,
height: CARD_H + 30,
alignItems: 'center',
justifyContent: 'flex-end',
},
card: {
position: 'absolute',
bottom: 0,
width: CARD_W,
height: CARD_H,
borderWidth: StyleSheet.hairlineWidth,
padding: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
tile: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
bar: { height: 6, borderRadius: 3 },
dot: {
position: 'absolute',
top: -6,
right: -6,
width: 16,
height: 16,
borderRadius: 8,
borderWidth: 3,
},
copy: { alignItems: 'center', maxWidth: 320 },
center: { textAlign: 'center' },
actions: { alignItems: 'center', alignSelf: 'stretch' },
primary: { height: 48, paddingHorizontal: 28, alignItems: 'center', justifyContent: 'center' },
secondary: {
minHeight: 48,
paddingHorizontal: 12,
alignItems: 'center',
justifyContent: 'center',
},
});Installation
npx kinetik-ui add empty-statepnpm dlx kinetik-ui add empty-statebunx kinetik-ui add empty-stateyarn dlx kinetik-ui add empty-stateThe 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/empty-state.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/empty-state.tsx
Usage
import { EmptyState } from '@/components/kinetik/empty-state';
<EmptyState
icon={<BookmarkIcon color={theme.colors.azure} />}
title="No saved places yet"
description="Tap the bookmark on any place you like and it will wait for you here."
action={{ label: 'Explore places', onPress: openExplore }}
secondaryAction={{ label: 'How saving works', onPress: openHelp }}
/>;The illustration is made of three cards, with your icon on the front one, so it takes the theme's colours in light and dark without any artwork.
When to use
- Lists and screens with nothing in them yet: a new account, an empty inbox, a search with no results.
- Moments where the next step is clear and worth a button.
When not to use
- While content is loading. Use Skeleton Sweep.
- Errors. Say what went wrong and how to fix it instead.
Notes
- The entrance plays once. Pass a new
playKey, such as the current filter, to replay it when the reason for being empty changes. - Write the title as a plain statement and the description as what to do next.
- With reduced motion everything is in place from the start.
Props
EmptyStateProps
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | string | — | |
description | string | — | |
icon | ReactNode | — | Drawn on the front card of the illustration, e.g. an inbox or search icon. |
action | EmptyStateAction | — | The next step, as a solid button. |
secondaryAction | EmptyStateAction | — | A quieter alternative, as a text button. |
playKey | string | number | — | Replays the entrance when it changes, e.g. the current filter. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Plain views and five shared values that play once on mount. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.85× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 143 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- Everything is in place from the start.
- Screen readers
- The illustration is hidden. The title is a header, followed by the description and the action buttons.
- Touch target
- The main button is 48 pt tall and the text button 48 pt.
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/empty-state.tsx
import { useEffect, type ReactNode } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
type SharedValue,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type EmptyStateAction = { label: string; onPress: () => void };
export type EmptyStateProps = {
title: string;
description?: string;
/** Drawn on the front card of the illustration, e.g. an inbox or search icon. */
icon?: ReactNode;
/** The next step, as a solid button. */
action?: EmptyStateAction;
/** A quieter alternative, as a text button. */
secondaryAction?: EmptyStateAction;
/** Replays the entrance when it changes, e.g. the current filter. */
playKey?: string | number;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const CARD_W = 112;
const CARD_H = 84;
/**
* A calm empty screen with somewhere to go next. Three cards rise and fan
* out one after another, a blue dot lands on the front one, and the text and
* actions settle in beneath. It plays once, then stays still.
*/
export function EmptyState({
title,
description,
icon,
action,
secondaryAction,
playKey,
testID,
style,
}: EmptyStateProps) {
const { colors, font, radius, space, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const back = useSharedValue(0);
const middle = useSharedValue(0);
const front = useSharedValue(0);
const dot = useSharedValue(0);
const text = useSharedValue(0);
useEffect(() => {
const cards = [back, middle, front];
if (reduced) {
[...cards, dot, text].forEach((v) => v.set(1));
return;
}
[...cards, dot, text].forEach((v) => v.set(0));
cards.forEach((v, i) => v.set(withDelay(i * 90, withSpring(1, spring('smooth')))));
dot.set(withDelay(420, withSpring(1, spring('bouncy'))));
text.set(withDelay(200, withSpring(1, spring('smooth'))));
}, [reduced, playKey, back, middle, front, dot, text]);
const textStyle = useAnimatedStyle(() => ({
opacity: text.get(),
transform: [{ translateY: (1 - text.get()) * 10 }],
}));
const dotStyle = useAnimatedStyle(() => ({
opacity: Math.min(1, dot.get() * 2),
transform: [{ scale: dot.get() }],
}));
return (
<View testID={testID} style={[styles.root, { gap: space[5] }, style]}>
<View
style={styles.art}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{/* Back to front: the two behind turn away from the middle as they rise. */}
<Card
t={back}
angle={-9}
dx={-22}
background={colors.surface}
border={colors.border}
radius={radius.lg}
/>
<Card
t={middle}
angle={8}
dx={22}
background={colors.surface}
border={colors.border}
radius={radius.lg}
/>
<Card
t={front}
angle={0}
dx={0}
background={colors.surfaceRaised}
border={colors.borderStrong}
radius={radius.lg}
lift={10}
shadowStyle={shadow.md}
>
<View
style={[styles.tile, { backgroundColor: colors.azureSoft, borderRadius: radius.sm }]}
>
{icon}
</View>
<View style={{ flex: 1, gap: 6 }}>
<View style={[styles.bar, { width: '80%', backgroundColor: colors.track }]} />
<View style={[styles.bar, { width: '50%', backgroundColor: colors.track }]} />
</View>
<Animated.View
style={[
styles.dot,
{ backgroundColor: colors.azure, borderColor: colors.surfaceRaised },
dotStyle,
]}
/>
</Card>
</View>
<Animated.View style={[styles.copy, { gap: space[2] }, textStyle]}>
<Text
accessibilityRole="header"
style={[
styles.center,
{ color: colors.text, fontSize: font.size.xl, fontWeight: font.weight.semibold },
]}
>
{title}
</Text>
{description ? (
<Text
style={[
styles.center,
{ color: colors.textMuted, fontSize: font.size.md, lineHeight: 22 },
]}
>
{description}
</Text>
) : null}
{action || secondaryAction ? (
<View style={[styles.actions, { marginTop: space[3], gap: space[1] }]}>
{action ? (
<Pressable
onPress={action.onPress}
accessibilityRole="button"
style={({ pressed }) => [
styles.primary,
{
backgroundColor: colors.ember,
borderRadius: radius.pill,
transform: [{ scale: pressed && !reduced ? 0.97 : 1 }],
},
]}
>
<Text
style={{
color: colors.onEmber,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
}}
>
{action.label}
</Text>
</Pressable>
) : null}
{secondaryAction ? (
<Pressable
onPress={secondaryAction.onPress}
accessibilityRole="button"
style={({ pressed }) => [styles.secondary, { opacity: pressed ? 0.6 : 1 }]}
>
<Text
style={{
color: colors.textMuted,
fontSize: font.size.sm,
fontWeight: font.weight.semibold,
}}
>
{secondaryAction.label}
</Text>
</Pressable>
) : null}
</View>
) : null}
</Animated.View>
</View>
);
}
type CardProps = {
t: SharedValue<number>;
angle: number;
dx: number;
background: string;
border: string;
radius: number;
lift?: number;
shadowStyle?: string;
children?: ReactNode;
};
function Card({
t,
angle,
dx,
background,
border,
radius,
lift = 0,
shadowStyle,
children,
}: CardProps) {
const style = useAnimatedStyle(() => {
const p = t.get();
return {
opacity: Math.min(1, p * 1.5),
transform: [
{ translateX: dx * p },
{ translateY: (1 - p) * 26 - lift * p },
{ rotate: `${angle * p}deg` },
{ scale: 0.9 + p * 0.1 },
],
};
});
return (
<Animated.View
style={[
styles.card,
{
backgroundColor: background,
borderColor: border,
borderRadius: radius,
boxShadow: shadowStyle,
},
style,
]}
>
{children}
</Animated.View>
);
}
const styles = StyleSheet.create({
root: { alignItems: 'center', paddingVertical: 8 },
art: {
width: CARD_W + 60,
height: CARD_H + 30,
alignItems: 'center',
justifyContent: 'flex-end',
},
card: {
position: 'absolute',
bottom: 0,
width: CARD_W,
height: CARD_H,
borderWidth: StyleSheet.hairlineWidth,
padding: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
tile: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
bar: { height: 6, borderRadius: 3 },
dot: {
position: 'absolute',
top: -6,
right: -6,
width: 16,
height: 16,
borderRadius: 8,
borderWidth: 3,
},
copy: { alignItems: 'center', maxWidth: 320 },
center: { textAlign: 'center' },
actions: { alignItems: 'center', alignSelf: 'stretch' },
primary: { height: 48, paddingHorizontal: 28, alignItems: 'center', justifyContent: 'center' },
secondary: {
minHeight: 48,
paddingHorizontal: 12,
alignItems: 'center',
justifyContent: 'center',
},
});