Kinetik UI

Flip Ledger

.md

A two-sided card that turns over in 3D, sized to the taller of its faces.

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

Installation

Kinetik CLI
npx kinetik-ui add flip-ledger

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/flip-ledger.json

Install manually

  1. Install dependencies
    npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/flip-ledger.tsx
    View source files

Usage

import { FlipLedger } from '@/components/kinetik/flip-ledger';

<FlipLedger
  accessibilityLabel="October ledger"
  front={<Balance />}
  back={<RecentTransactions />}
/>;

Uncontrolled by default: a tap turns the card over. Pass flipped and onFlippedChange to drive it from state, or flipOnPress={false} to flip only from code.

When to use

  • Two closely related views of one object: a summary and its detail, a question and its answer, a front and a back.
  • Content that fits on a card; both faces take the height of the taller one.

When not to use

  • Hiding information the user needs to compare side by side. Show both instead.
  • Long or scrolling content on either face. Navigate to a detail screen.
  • Forms. Inputs on a face that turns away are easy to lose.

Notes

  • Only the visible face is exposed to screen readers and receives touches.
  • The axis can be y (like a page) or x (like a flap). Reduced motion swaps the rotation for a crossfade.

Props

FlipLedgerProps

PropTypeDefaultDescription
frontrequiredReactNode—Face shown when not flipped.
backrequiredReactNode—Face shown when flipped.
flippedboolean—Controlled flip state. Leave undefined to let the card manage it.
defaultFlippedbooleanfalseInitial state when uncontrolled. Defaults to false.
onFlippedChange(flipped: boolean) => void—Called with the next state whenever the card is flipped by the user.
axis'x' | 'y''y'Rotation axis. `y` flips like a page, `x` like a flap. Defaults to `y`.
flipOnPressbooleantrueFlip when tapped. Defaults to true. Set false to drive it only through `flipped`.
hapticsbooleantruePlays a light tap when the card lands. Defaults to true.
accessibilityLabelstring—Accessibility label for the card as a whole.
radiusnumber—Corner radius. Defaults to the theme `xl` radius.
styleStyleProp<ViewStyle>—
faceStyleStyleProp<ViewStyle>—Style applied to both faces (padding, background).

Performance and accessibility

Performance

Budget
Idle: no per-frame work. Flipping: two transforms and two opacities for ~400 ms on the UI thread.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.06× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 150 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
Crossfades between faces instead of rotating; no lift.
Screen readers
Button role with expanded state; only the visible face is exposed. 'Activate' flips the card.
Touch target
The whole card is the target.

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/flip-ledger.tsx
import { useEffect, useState, type ReactNode } from 'react';
import {
  StyleSheet,
  View,
  type AccessibilityActionEvent,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  interpolate,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} 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, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';

export type FlipLedgerProps = {
  /** Face shown when not flipped. */
  front: ReactNode;
  /** Face shown when flipped. */
  back: ReactNode;
  /** Controlled flip state. Leave undefined to let the card manage it. */
  flipped?: boolean;
  /** Initial state when uncontrolled. Defaults to false. */
  defaultFlipped?: boolean;
  /** Called with the next state whenever the card is flipped by the user. */
  onFlippedChange?: (flipped: boolean) => void;
  /** Rotation axis. `y` flips like a page, `x` like a flap. Defaults to `y`. */
  axis?: 'x' | 'y';
  /** Flip when tapped. Defaults to true. Set false to drive it only through `flipped`. */
  flipOnPress?: boolean;
  /** Plays a light tap when the card lands. Defaults to true. */
  haptics?: boolean;
  /** Accessibility label for the card as a whole. */
  accessibilityLabel?: string;
  /** Corner radius. Defaults to the theme `xl` radius. */
  radius?: number;
  style?: StyleProp<ViewStyle>;
  /** Style applied to both faces (padding, background). */
  faceStyle?: StyleProp<ViewStyle>;
};

/** A two-sided card that turns over in 3D, sized to the taller of its faces. */
export function FlipLedger({
  front,
  back,
  flipped: controlled,
  defaultFlipped = false,
  onFlippedChange,
  axis = 'y',
  flipOnPress = true,
  haptics = true,
  accessibilityLabel,
  radius,
  style,
  faceStyle,
}: FlipLedgerProps) {
  const theme = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const [internal, setInternal] = useState(defaultFlipped);
  const [heights, setHeights] = useState({ front: 0, back: 0 });
  const contentHeight = Math.max(heights.front, heights.back);
  const flipped = controlled ?? internal;
  const r = radius ?? theme.radius.xl;

  // 0 = front, 1 = back. Rotation and lift both derive from it.
  const progress = useSharedValue(flipped ? 1 : 0);
  const press = useSharedValue(0);

  useEffect(() => {
    const target = flipped ? 1 : 0;
    if (reduced) {
      progress.set(withTiming(target, timing('base')));
    } else {
      progress.set(withSpring(target, spring('smooth')));
    }
  }, [flipped, reduced, progress]);

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

  const tap = Gesture.Tap()
    .withTestId('flip-ledger')
    .enabled(flipOnPress)
    .onBegin(() => {
      press.set(withSpring(1, spring('snappy', reduced)));
    })
    .onFinalize(() => {
      press.set(withSpring(0, spring('snappy', reduced)));
    })
    .onEnd(() => {
      scheduleOnRN(toggle);
    });

  const flipX = axis === 'x';

  const container = useAnimatedStyle(() => {
    // The card lifts slightly at the midpoint of the turn and when pressed.
    const lift = Math.sin(progress.get() * Math.PI);
    return {
      transform: [{ scale: 1 - press.get() * 0.02 + (reduced ? 0 : lift * 0.04) }],
    };
  });

  const frontStyle = useAnimatedStyle(() => {
    const p = progress.get();
    if (reduced) return { opacity: 1 - p };
    return { opacity: p < 0.5 ? 1 : 0, transform: turn(p * 180, flipX) };
  });

  const backStyle = useAnimatedStyle(() => {
    const p = progress.get();
    if (reduced) return { opacity: p };
    return { opacity: p >= 0.5 ? 1 : 0, transform: turn(p * 180 - 180, flipX) };
  });

  // A shade sweeps across each face as it turns away from the viewer.
  const frontShade = useAnimatedStyle(() => ({
    opacity: reduced ? 0 : interpolate(progress.get(), [0, 0.5], [0, 0.35], 'clamp'),
  }));
  const backShade = useAnimatedStyle(() => ({
    opacity: reduced ? 0 : interpolate(progress.get(), [0.5, 1], [0.35, 0], 'clamp'),
  }));

  // Both faces take the height of the taller one, so nothing clips mid-turn.
  const measure = (side: 'front' | 'back', height: number) => {
    setHeights((h) => (Math.abs(h[side] - height) < 0.5 ? h : { ...h, [side]: height }));
  };

  const onAccessibilityAction = (e: AccessibilityActionEvent) => {
    if (e.nativeEvent.actionName === 'activate' && flipOnPress) toggle();
  };

  const face: StyleProp<ViewStyle> = [
    styles.face,
    {
      borderRadius: r,
      backgroundColor: theme.colors.surface,
      borderColor: theme.colors.border,
      padding: theme.space[5],
      boxShadow: theme.shadow.md,
    },
    faceStyle,
  ];
  const shade = [styles.shade, { borderRadius: r, backgroundColor: theme.colors.scrim }];

  return (
    <GestureDetector gesture={tap}>
      <Animated.View
        collapsable={false}
        accessible
        accessibilityRole={flipOnPress ? 'button' : undefined}
        accessibilityLabel={accessibilityLabel}
        accessibilityHint={flipOnPress ? 'Turns the card over' : undefined}
        accessibilityState={{ expanded: flipped }}
        accessibilityActions={flipOnPress ? [{ name: 'activate' }] : undefined}
        onAccessibilityAction={onAccessibilityAction}
        style={[styles.root, style, container]}
      >
        <Animated.View
          style={[face, frontStyle]}
          pointerEvents={flipped ? 'none' : 'auto'}
          importantForAccessibility={flipped ? 'no-hide-descendants' : 'auto'}
          accessibilityElementsHidden={flipped}
        >
          <View style={{ minHeight: contentHeight }}>
            <View onLayout={(e) => measure('front', e.nativeEvent.layout.height)}>{front}</View>
          </View>
          <Animated.View pointerEvents="none" style={[shade, frontShade]} />
        </Animated.View>
        <Animated.View
          style={[face, styles.back, backStyle]}
          pointerEvents={flipped ? 'auto' : 'none'}
          importantForAccessibility={flipped ? 'auto' : 'no-hide-descendants'}
          accessibilityElementsHidden={!flipped}
        >
          <View style={{ minHeight: contentHeight }}>
            <View onLayout={(e) => measure('back', e.nativeEvent.layout.height)}>{back}</View>
          </View>
          <Animated.View pointerEvents="none" style={[shade, backShade]} />
        </Animated.View>
      </Animated.View>
    </GestureDetector>
  );
}

function turn(deg: number, flipX: boolean) {
  'worklet';
  return flipX
    ? [{ perspective: 1200 }, { rotateX: `${-deg}deg` }]
    : [{ perspective: 1200 }, { rotateY: `${deg}deg` }];
}

const styles = StyleSheet.create({
  root: {},
  face: {
    borderWidth: StyleSheet.hairlineWidth,
    overflow: 'hidden',
    backfaceVisibility: 'hidden',
  },
  back: { position: 'absolute', top: 0, left: 0, right: 0 },
  shade: { ...StyleSheet.absoluteFill },
});