Kinetik UI

Empty State

.md

A calm empty screen: three cards rise and fan out, a blue dot lands, and the next step settles in beneath.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add empty-state

The 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

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/empty-state.tsx
    View source files

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

PropTypeDefaultDescription
titlerequiredstring—
descriptionstring—
iconReactNode—Drawn on the front card of the illustration, e.g. an inbox or search icon.
actionEmptyStateAction—The next step, as a solid button.
secondaryActionEmptyStateAction—A quieter alternative, as a text button.
playKeystring | number—Replays the entrance when it changes, e.g. the current filter.
testIDstring—
styleStyleProp<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',
  },
});