# Expand Card

> A row that opens into a card: the thumbnail grows into a wide picture and the details rise in beneath it.

- Category: Card
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `react-native-gesture-handler`, `react-native-reanimated`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add expand-card
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/expand-card.json
```

## Usage

```tsx
import { ExpandCard } from '@/components/kinetik/expand-card';

<ExpandCard image={{ uri: trip.photo }} title={trip.name} subtitle={trip.dates}>
  <Text>{trip.summary}</Text>
</ExpandCard>;
```

Uncontrolled by default. Pass `expanded` and `onExpandedChange` to drive it, for example to keep only one card of a list open at a time.

## When to use

- Lists where each row has a picture and a little more to say: trips, recipes, events, artworks.
- Settings or FAQs that benefit from a visual anchor.

## When not to use

- Long content. Navigate to a detail screen instead.
- Rows without an image; a plain disclosure row is clearer.

## Notes

- The card measures its title and body, so the open height always fits the content.
- Children are laid out at the card's width; keep interactive content out of the body, since a tap on the card toggles it.
- A light tap plays as it opens and closes.

## ExpandCardProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `image` | `ImageSourcePropType` |  |  |
| `title` | `string` |  |  |
| `subtitle`? | `string` |  |  |
| `children`? | `ReactNode` |  | Shown under the title once the card is open. |
| `expanded`? | `boolean` |  | Controlled open state. |
| `defaultExpanded`? | `boolean` | `false` | Initial state when uncontrolled. Defaults to false. |
| `onExpandedChange`? | `(expanded: boolean) => void` |  |  |
| `imageAspect`? | `number` | `0.62` | Image height divided by width when open. Defaults to 0.62. |
| `thumbSize`? | `number` | `64` | Size of the closed thumbnail. Defaults to 64. |
| `haptics`? | `boolean` | `true` | A light tap when it opens or closes. Defaults to true. |
| `style`? | `StyleProp<ViewStyle>` |  |  |

## Accessibility

- Reduced motion: Opens and closes in one step, without the morph.
- Screen readers: A button named by the title and subtitle with an expanded state; the body is hidden from screen readers while closed.
- Touch target: The whole row, at least 88 pt tall.

## Performance

One spring drives the card height, picture size and title position on the UI thread; nothing runs at rest.

- Real-device measurement pending.

## Source

`components/kinetik/expand-card.tsx`

```tsx
import { useEffect, useState, type ReactNode } from 'react';
import {
  Image,
  StyleSheet,
  Text,
  View,
  type AccessibilityActionEvent,
  type ImageSourcePropType,
  type LayoutChangeEvent,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  interpolate,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

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 ExpandCardProps = {
  image: ImageSourcePropType;
  title: string;
  subtitle?: string;
  /** Shown under the title once the card is open. */
  children?: ReactNode;
  /** Controlled open state. */
  expanded?: boolean;
  /** Initial state when uncontrolled. Defaults to false. */
  defaultExpanded?: boolean;
  onExpandedChange?: (expanded: boolean) => void;
  /** Image height divided by width when open. Defaults to 0.62. */
  imageAspect?: number;
  /** Size of the closed thumbnail. Defaults to 64. */
  thumbSize?: number;
  /** A light tap when it opens or closes. Defaults to true. */
  haptics?: boolean;
  style?: StyleProp<ViewStyle>;
};

const PAD = 12;
const GAP = 14;

/**
 * A row that opens into a card. The thumbnail grows into a wide picture,
 * the title slides under it and the body rises in; tap again and it all
 * folds back into the row.
 */
export function ExpandCard({
  image,
  title,
  subtitle,
  children,
  expanded: controlled,
  defaultExpanded = false,
  onExpandedChange,
  imageAspect = 0.62,
  thumbSize = 64,
  haptics = true,
  style,
}: ExpandCardProps) {
  const { colors, font, radius, space, shadow } = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [internal, setInternal] = useState(defaultExpanded);
  const open = controlled ?? internal;

  const [width, setWidth] = useState(0);
  const [titleH, setTitleH] = useState(0);
  const [bodyH, setBodyH] = useState(0);
  const p = useSharedValue(open ? 1 : 0);

  useEffect(() => {
    p.set(withSpring(open ? 1 : 0, spring('smooth', reduced)));
  }, [open, reduced, p]);

  const toggle = () => {
    const next = !open;
    haptic('tap');
    if (controlled === undefined) setInternal(next);
    onExpandedChange?.(next);
  };

  const tap = Gesture.Tap()
    .withTestId('expand-card')
    .maxDistance(16)
    .onEnd((_e, ok) => {
      if (ok) scheduleOnRN(toggle);
    });

  // Geometry in both states, from the measured width, title and body.
  const inner = Math.max(0, width - PAD * 2);
  const imageH = Math.round(inner * imageAspect);
  const closedH = PAD * 2 + thumbSize;
  const openH = PAD + imageH + GAP + titleH + (children ? space[3] + bodyH : 0) + PAD + 4;
  const titleClosed = { x: PAD + thumbSize + GAP, y: PAD + (thumbSize - titleH) / 2 };
  const titleOpen = { x: PAD + 4, y: PAD + imageH + GAP };

  const card = useAnimatedStyle(() => ({
    height: width === 0 ? closedH : interpolate(p.get(), [0, 1], [closedH, openH]),
  }));
  const picture = useAnimatedStyle(() => {
    const t = p.get();
    return {
      width: interpolate(t, [0, 1], [thumbSize, inner || thumbSize]),
      height: interpolate(t, [0, 1], [thumbSize, imageH || thumbSize]),
      borderRadius: interpolate(t, [0, 1], [radius.sm, radius.lg]),
    };
  });
  const heading = useAnimatedStyle(() => {
    const t = p.get();
    // The title dips out mid-journey so it never sits on the picture as it resizes.
    const mid = Math.sin(Math.PI * Math.max(0, Math.min(1, t)));
    return {
      opacity: 1 - mid * 0.92,
      width: interpolate(t, [0, 1], [inner - thumbSize - GAP - 36, inner - 44]),
      transform: [
        { translateX: interpolate(t, [0, 1], [titleClosed.x, titleOpen.x]) },
        { translateY: interpolate(t, [0, 1], [titleClosed.y, titleOpen.y]) },
      ],
    };
  });
  const chevron = useAnimatedStyle(() => {
    const t = p.get();
    return {
      transform: [
        {
          translateY: interpolate(
            t,
            [0, 1],
            [PAD + thumbSize / 2 - 15, titleOpen.y + titleH / 2 - 15],
          ),
        },
        { rotate: `${interpolate(t, [0, 1], [0, 180])}deg` },
      ],
    };
  });
  const body = useAnimatedStyle(() => {
    const t = p.get();
    return {
      opacity: interpolate(t, [0.45, 1], [0, 1], 'clamp'),
      transform: [
        { translateY: titleOpen.y + titleH + space[3] + interpolate(t, [0, 1], [14, 0]) },
      ],
    };
  });

  const onLayout = (e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width);
  const onAccessibilityAction = (e: AccessibilityActionEvent) => {
    if (e.nativeEvent.actionName === 'activate') toggle();
  };

  return (
    <GestureDetector gesture={tap}>
      <Animated.View
        collapsable={false}
        accessible
        accessibilityRole="button"
        accessibilityLabel={subtitle ? `${title}, ${subtitle}` : title}
        accessibilityState={{ expanded: open }}
        accessibilityActions={[{ name: 'activate' }]}
        onAccessibilityAction={onAccessibilityAction}
        onLayout={onLayout}
        style={[
          styles.card,
          {
            borderRadius: radius.xl,
            backgroundColor: colors.surface,
            borderColor: colors.border,
            boxShadow: shadow.md,
          },
          style,
          card,
        ]}
      >
        <Animated.View style={[styles.picture, picture]}>
          <Image source={image} style={StyleSheet.absoluteFill} resizeMode="cover" />
        </Animated.View>

        <Animated.View style={[styles.abs, heading]}>
          <View onLayout={(e) => setTitleH(Math.ceil(e.nativeEvent.layout.height))}>
            <Text
              numberOfLines={1}
              style={{
                color: colors.text,
                fontSize: font.size.md,
                fontWeight: font.weight.semibold,
              }}
            >
              {title}
            </Text>
            {subtitle ? (
              <Text
                numberOfLines={1}
                style={{ color: colors.textMuted, fontSize: font.size.sm, marginTop: 2 }}
              >
                {subtitle}
              </Text>
            ) : null}
          </View>
        </Animated.View>

        <Animated.View
          style={[
            styles.chevron,
            { right: PAD + 2, backgroundColor: colors.surfaceRaised, borderColor: colors.border },
            chevron,
          ]}
          pointerEvents="none"
        >
          <View style={[styles.chevronGlyph, { borderColor: colors.textMuted }]} />
        </Animated.View>

        {children ? (
          <Animated.View
            style={[styles.abs, { left: PAD + 4, right: PAD + 4 }, body]}
            importantForAccessibility={open ? 'auto' : 'no-hide-descendants'}
            accessibilityElementsHidden={!open}
          >
            <View onLayout={(e) => setBodyH(Math.ceil(e.nativeEvent.layout.height))}>
              {children}
            </View>
          </Animated.View>
        ) : null}
      </Animated.View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  card: { overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth },
  picture: { position: 'absolute', left: PAD, top: PAD, overflow: 'hidden' },
  abs: { position: 'absolute', left: 0, top: 0 },
  chevron: {
    position: 'absolute',
    top: 0,
    width: 30,
    height: 30,
    borderRadius: 15,
    alignItems: 'center',
    justifyContent: 'center',
    borderWidth: StyleSheet.hairlineWidth,
  },
  chevronGlyph: {
    width: 8,
    height: 8,
    marginTop: -3,
    borderRightWidth: 1.75,
    borderBottomWidth: 1.75,
    transform: [{ rotate: '45deg' }],
  },
});
```
