Kinetik UI

Skeleton Sweep

.md

Loading placeholders with one band of light sweeping across the whole layout.

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

Installation

Kinetik CLI
npx kinetik-ui add skeleton-sweep

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/skeleton-sweep.json

Install manually

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

Usage

import { Skeleton, SkeletonGroup } from '@/components/kinetik/skeleton-sweep';

<SkeletonGroup accessibilityLabel="Loading messages">
  <Skeleton circle={44} />
  <Skeleton width="60%" height={14} />
  <Skeleton width="90%" height={12} />
</SkeletonGroup>;

All skeletons in a group share one clock and offset the light by their position on screen, so one band of light passes over the whole layout.

When to use

  • Content whose shape is known before it loads: lists, profiles, cards.
  • Loads that usually take longer than about 300 ms.

When not to use

  • Very fast loads. A skeleton that flashes for 100 ms is noisier than nothing.
  • Unknown or highly variable layouts. Use a spinner or a progress bar.

Notes

  • Pass active={false} while the screen is out of focus to stop the sweep.
  • A Skeleton outside a group runs its own sweep.
  • The group is announced as a busy progress bar; the blocks themselves are hidden from screen readers.

Props

SkeletonGroupProps

PropTypeDefaultDescription
childrenrequiredReactNode—
activebooleantruePause the sweep, e.g. while the screen is not focused. Defaults to true.
periodnumber1400Time for one pass across the screen, in ms. Defaults to 1400.
accessibilityLabelstring'Loading'Announced to screen readers. Defaults to "Loading".
styleStyleProp<ViewStyle>—

SkeletonProps

PropTypeDefaultDescription
widthDimensionValue'100%'
heightDimensionValue14
radiusnumber—Corner radius. Defaults to the theme `sm` radius.
circlenumber—Shorthand for a circle of this diameter.
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
One repeating timing per group; each block applies a single translate. Pauses when `active` is false.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.18× the system Settings app on the same emulator (indicative only) · keeps drawing at rest · 142 MB app memory. Sweeps by design while loading; unmounts with the placeholder.

Real-device measurement pending.

Accessibility

Reduced motion
Static placeholders with no sweep.
Screen readers
The group is a busy progress bar with a label ('Loading' by default); individual blocks are hidden.
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/skeleton-sweep.tsx
import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react';
import {
  StyleSheet,
  View,
  useWindowDimensions,
  type DimensionValue,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import Animated, {
  Easing,
  cancelAnimation,
  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';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';

const BAND = 180;

type Sweep = { clock: SharedValue<number>; still: boolean };
const SweepContext = createContext<Sweep | null>(null);

/** One looping clock, 0 → 1 per pass. Stops when `active` is false or motion is reduced. */
function useSweepClock(active: boolean, period: number): Sweep {
  const reduced = useReduceMotion();
  const clock = useSharedValue(0);
  const still = reduced || !active;
  useEffect(() => {
    if (still) {
      cancelAnimation(clock);
      clock.set(0);
      return;
    }
    clock.set(0);
    clock.set(
      withRepeat(withTiming(1, { duration: period, easing: Easing.inOut(Easing.quad) }), -1),
    );
    return () => cancelAnimation(clock);
  }, [still, period, clock]);
  return { clock, still };
}

export type SkeletonGroupProps = {
  children: ReactNode;
  /** Pause the sweep, e.g. while the screen is not focused. Defaults to true. */
  active?: boolean;
  /** Time for one pass across the screen, in ms. Defaults to 1400. */
  period?: number;
  /** Announced to screen readers. Defaults to "Loading". */
  accessibilityLabel?: string;
  style?: StyleProp<ViewStyle>;
};

/**
 * Shares one sweep between every `Skeleton` inside it. Each skeleton offsets
 * the light by its position on screen, so a single band of light passes over
 * the whole layout instead of each block shimmering on its own.
 */
export function SkeletonGroup({
  children,
  active = true,
  period = 1400,
  accessibilityLabel = 'Loading',
  style,
}: SkeletonGroupProps) {
  const sweep = useSweepClock(active, period);
  return (
    <SweepContext.Provider value={sweep}>
      <View
        accessible
        accessibilityRole="progressbar"
        accessibilityLabel={accessibilityLabel}
        accessibilityState={{ busy: true }}
        style={style}
      >
        {children}
      </View>
    </SweepContext.Provider>
  );
}

export type SkeletonProps = {
  width?: DimensionValue;
  height?: DimensionValue;
  /** Corner radius. Defaults to the theme `sm` radius. */
  radius?: number;
  /** Shorthand for a circle of this diameter. */
  circle?: number;
  style?: StyleProp<ViewStyle>;
};

/** A placeholder block with a sweeping highlight. Use inside a `SkeletonGroup`. */
export function Skeleton({ width = '100%', height = 14, radius, circle, style }: SkeletonProps) {
  const { colors, radius: radii } = useKinetikTheme();
  const shared = useContext(SweepContext);
  const own = useSweepClock(shared === null, 1400);
  const { clock, still } = shared ?? own;
  const { width: screen } = useWindowDimensions();
  const ref = useRef<View>(null);
  const pageX = useSharedValue(0);
  const remeasure = useRef<ReturnType<typeof setTimeout> | null>(null);
  useEffect(
    () => () => {
      if (remeasure.current) clearTimeout(remeasure.current);
    },
    [],
  );

  // Measure on layout, and again once screen transitions have settled: a
  // measurement taken mid-transition includes the sliding screen's offset.
  const measure = () => {
    ref.current?.measureInWindow((x) => pageX.set(x));
    if (remeasure.current) clearTimeout(remeasure.current);
    remeasure.current = setTimeout(() => ref.current?.measureInWindow((x) => pageX.set(x)), 500);
  };

  const band = useAnimatedStyle(() => {
    // The band travels from off-screen left to off-screen right in screen space.
    const x = -BAND + clock.get() * (screen + BAND * 2) - pageX.get();
    return { transform: [{ translateX: x }] };
  });

  const size = circle
    ? { width: circle, height: circle, borderRadius: circle / 2 }
    : { width, height, borderRadius: radius ?? radii.sm };
  const light = colors.skeletonHighlight;

  return (
    <View
      ref={ref}
      importantForAccessibility="no-hide-descendants"
      accessibilityElementsHidden
      onLayout={measure}
      style={[styles.block, size, { backgroundColor: colors.skeleton }, style]}
    >
      {!still && (
        <Animated.View
          pointerEvents="none"
          style={[
            styles.band,
            gradient(
              `linear-gradient(90deg, ${withAlpha(light, 0)} 0%, ${light} 50%, ${withAlpha(light, 0)} 100%)`,
            ),
            band,
          ]}
        />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  block: { overflow: 'hidden' },
  band: { position: 'absolute', top: 0, bottom: 0, left: 0, width: BAND },
});