Kinetik UI

Fan Stack

.md

A loose pile of pictures that fans out like a hand of cards; tap one to lift it out of the fan.

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

Installation

Kinetik CLI
npx kinetik-ui add fan-stack

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/fan-stack.json

Install manually

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

Usage

import { FanStack } from '@/components/kinetik/fan-stack';

<FanStack
  accessibilityLabel="Desert trip"
  items={photos.map((p) => ({ key: p.id, image: { uri: p.url }, title: p.title }))}
  onSelect={(i) => setFocus(i)}
/>;

The stack centres itself in the width it is given and reserves the height the fan needs. The last item lies on top of the pile.

When to use

  • A small set of pictures that belongs together: an album, a trip, a moodboard.
  • Previews where the playful gesture is part of the charm, such as a profile or a shared memory.

When not to use

  • More than seven items. Use a grid or a carousel.
  • Pictures people need to compare side by side. The fan overlaps them.

Notes

  • Tap the pile to fan it out, tap a card to lift it, tap the lifted card to gather the pile.
  • Pass open and onOpenChange to fan it out from elsewhere, for example when its section scrolls into view.
  • With reduced motion the cards move without the stagger or the bounce.

Props

FanStackProps

PropTypeDefaultDescription
itemsrequiredreadonly FanItem[]—Two to seven pictures; the last one lies on top of the pile.
cardWidthnumber148Width of each card. Defaults to 148.
aspectnumber1.3Card height divided by width. Defaults to 1.3.
spreadnumber11Degrees between neighbouring cards when fanned. Defaults to 11.
openboolean—Controlled fan state. Leave unset to let the stack keep its own.
onOpenChange(open: boolean) => void—
onSelect(index: number | null) => void—Called with the lifted card, or null when none is.
accessibilityLabelstring'Photos'Names the pile for screen readers. Defaults to 'Photos'.
hapticsbooleantrueA tap as it fans out and a tick as a card lifts. Defaults to true.
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
Three springs per card (fan, lift, dim), transforms and opacity only. Nothing runs at rest.
Measured

Real-device measurement pending.

Accessibility

Reduced motion
Cards move straight to the fan or the pile without the stagger or the bounce.
Screen readers
Gathered, the pile is one button ('Photos, 5 items'); fanned, every card is a button with a selected state.
Touch target
Cards are 148 by 192 pt by default; overlapping cards keep at least 60 pt of their width uncovered.

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/fan-stack.tsx
import { useEffect, useState } from 'react';
import {
  Image,
  Pressable,
  StyleSheet,
  View,
  type ImageSourcePropType,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withSpring,
} from 'react-native-reanimated';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
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 FanItem = {
  key: string;
  image: ImageSourcePropType;
  /** Names the card for screen readers. */
  title: string;
};

export type FanStackProps = {
  /** Two to seven pictures; the last one lies on top of the pile. */
  items: readonly FanItem[];
  /** Width of each card. Defaults to 148. */
  cardWidth?: number;
  /** Card height divided by width. Defaults to 1.3. */
  aspect?: number;
  /** Degrees between neighbouring cards when fanned. Defaults to 11. */
  spread?: number;
  /** Controlled fan state. Leave unset to let the stack keep its own. */
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Called with the lifted card, or null when none is. */
  onSelect?: (index: number | null) => void;
  /** Names the pile for screen readers. Defaults to 'Photos'. */
  accessibilityLabel?: string;
  /** A tap as it fans out and a tick as a card lifts. Defaults to true. */
  haptics?: boolean;
  style?: StyleProp<ViewStyle>;
};

/** Each card's lie in the pile, in degrees: loose, never neat. */
const PILE = [-5, 4, -8, 7, -2, 6, -6];
/** Delay between cards as they fan out, in ms. */
const STAGGER = 24;

/**
 * A loose pile of pictures that fans out like a hand of cards. Tap the pile
 * to spread it; tap a card to lift it out of the fan, and tap it again to
 * gather them all back up.
 */
export function FanStack({
  items,
  cardWidth = 148,
  aspect = 1.3,
  spread = 11,
  open,
  onOpenChange,
  onSelect,
  accessibilityLabel = 'Photos',
  haptics = true,
  style,
}: FanStackProps) {
  const { radius } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [inner, setInner] = useState(false);
  const [selected, setSelected] = useState<number | null>(null);
  const isOpen = open ?? inner;
  const lifted = isOpen ? selected : null;

  const n = items.length;
  const height = Math.round(cardWidth * aspect);

  const setOpen = (next: boolean) => {
    if (open === undefined) setInner(next);
    onOpenChange?.(next);
  };
  const lift = (i: number | null) => {
    setSelected(i);
    onSelect?.(i);
  };

  const press = (i: number) => {
    if (!isOpen) {
      haptic('tap');
      setSelected(null);
      setOpen(true);
    } else if (lifted === i) {
      // A second tap on the lifted card gathers the pile.
      lift(null);
      setOpen(false);
    } else {
      haptic('tick');
      lift(i);
    }
  };

  return (
    <View
      style={[
        styles.stage,
        // Room for the fan's swing and the lifted card.
        { height: height + 64, minWidth: cardWidth * 2 },
        style,
      ]}
    >
      {items.map((item, i) => (
        <Card
          key={item.key}
          item={item}
          index={i}
          count={n}
          width={cardWidth}
          height={height}
          radius={radius.lg}
          spread={spread}
          open={isOpen}
          state={lifted === null ? 'rest' : lifted === i ? 'lifted' : 'behind'}
          reduced={reduced}
          // Closed, the pile is one control: the top card stands for it.
          role={isOpen ? 'card' : i === n - 1 ? 'pile' : 'hidden'}
          pileLabel={`${accessibilityLabel}, ${n} items`}
          onPress={() => press(i)}
        />
      ))}
    </View>
  );
}

function Card({
  item,
  index,
  count,
  width,
  height,
  radius,
  spread,
  open,
  state,
  reduced,
  role,
  pileLabel,
  onPress,
}: {
  item: FanItem;
  index: number;
  count: number;
  width: number;
  height: number;
  radius: number;
  spread: number;
  open: boolean;
  state: 'rest' | 'lifted' | 'behind';
  reduced: boolean;
  role: 'card' | 'pile' | 'hidden';
  pileLabel: string;
  onPress: () => void;
}) {
  const { colors, shadow } = useKinetikTheme();
  const fan = useSharedValue(open ? 1 : 0);
  const up = useSharedValue(state === 'lifted' ? 1 : 0);
  const dim = useSharedValue(state === 'behind' ? 1 : 0);
  const pressed = useSharedValue(0);

  useEffect(() => {
    // Out from the top of the pile down; back in the other way.
    const order = open ? count - 1 - index : index;
    const to = withSpring(open ? 1 : 0, spring(open ? 'bouncy' : 'smooth', reduced));
    fan.set(reduced ? to : withDelay(order * STAGGER, to));
  }, [open, index, count, reduced, fan]);
  useEffect(() => {
    up.set(withSpring(state === 'lifted' ? 1 : 0, spring('bouncy', reduced)));
    dim.set(withSpring(state === 'behind' ? 1 : 0, spring('smooth', reduced)));
  }, [state, reduced, up, dim]);

  const offset = index - (count - 1) / 2;
  const pile = PILE[index % PILE.length]!;
  const angle = offset * spread;
  const x = offset * width * 0.44;
  // Cards further from the middle dip, so the fan follows an arc.
  const dip = offset * offset * 5;

  const style = useAnimatedStyle(() => {
    const f = fan.get();
    const u = up.get();
    return {
      opacity: 1 - dim.get() * 0.35,
      transform: [
        { translateX: x * f },
        // Stacked a little higher card by card in the pile; on the arc when fanned.
        { translateY: -index * 1.5 + (dip + index * 1.5) * f - u * 30 },
        { rotate: `${pile + (angle - pile) * f - angle * 0.7 * u}deg` },
        { scale: (1 + u * 0.07) * (1 - pressed.get() * 0.03) },
      ],
    };
  });

  return (
    <Animated.View
      style={[
        styles.card,
        {
          width,
          height,
          marginLeft: -width / 2,
          borderRadius: radius,
          boxShadow: shadow.lg,
          backgroundColor: colors.surfaceRaised,
          zIndex: state === 'lifted' ? count + 1 : index,
        },
        style,
      ]}
      accessibilityElementsHidden={role === 'hidden'}
      importantForAccessibility={role === 'hidden' ? 'no-hide-descendants' : 'auto'}
    >
      <Pressable
        onPress={onPress}
        onPressIn={() => pressed.set(withSpring(1, spring('snappy', reduced)))}
        onPressOut={() => pressed.set(withSpring(0, spring('bouncy', reduced)))}
        accessibilityRole="button"
        accessibilityLabel={role === 'pile' ? pileLabel : item.title}
        accessibilityHint={role === 'pile' ? 'Spreads them out' : undefined}
        accessibilityState={role === 'card' ? { selected: state === 'lifted' } : undefined}
        style={[styles.press, { borderRadius: radius, borderColor: colors.border }]}
      >
        <Image source={item.image} style={StyleSheet.absoluteFill} resizeMode="cover" />
      </Pressable>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  stage: { width: '100%', alignItems: 'center' },
  card: {
    position: 'absolute',
    left: '50%',
    bottom: 24,
    // Turns about the bottom edge, like cards held in a hand.
    transformOrigin: 'bottom',
  },
  press: { flex: 1, overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth },
});