Expokit
AboutDocsComponentsChartsBlocksPricing
Sign inGet started
Overview3D ButtonCard StackProCube PagerProFlip CardGift CardProLoyalty CardProParallax LayersProParallax ScrollProPayment CardRing CarouselProSwipe DeckProTicketProTilt CardWheel ListPro
Tilt Card
Sponsor ExpoKit

Free stays free. Tip once — keep shipping.

Support
About
Docs
IntroductionWhat ExpoKit is and how it worksInstallationAdd ExpoKit to an Expo appThemingColours, radius and dark modeCLIinit, add, login and updateAI assistants (MCP)Cursor, Claude, Antigravity and moreRun on your phoneTry components in Expo GoChangelogWhat shipped recently
npx @expokit/cli@latest initQuick start
Components
Liquid Glass8 iOS 26 glass pieces3DNew14 tilt, flip and stack
Inputs 12
  • CalendarNew
  • Date PickerNew
  • Floating Input
  • OTP Input
  • Password Input
  • +7 more
Selection 7
  • Checkbox
  • Chip Group
  • Color SwatchPro
  • Radio Group
  • RatingPro
  • +2 more
Buttons 7
  • Button
  • Floating Action ButtonPro
  • Like ButtonPro
  • Morph ButtonPro
  • Shimmer ButtonPro
  • +2 more
Feedback 5
  • Alert
  • Progress
  • Skeleton
  • Spinner
  • Toast
Overlays 4
  • Action SheetNew
  • Bottom Sheet
  • Dialog
  • DrawerNew
Display 9
  • Accordion
  • Avatar
  • Badge
  • Card
  • CarouselPro
  • +4 more
Navigation 3
  • Bottom NavNew
  • Progress StepsNew
  • Tabs
Motion 2
  • Number TickerPro
  • Text Animation
70 components · 347 live variantsAll components
Charts

Cartesian

  • Area Chart
  • Bar Chart
  • Line Chart
  • Combo Chart

Circular

  • Pie & Donut
  • Radial Chart
  • Radar Chart
  • Polar AreaPro

3D & Pixel

  • Isometric BarsPro
  • Pixel BarsPro

Specialty

  • TreemapPro
  • WaterfallPro
  • BulletPro
  • TimelinePro
  • Gauge
  • Candlestick
  • Heatmap
  • Funnel

Classic

  • Sparkline
  • Progress Rings
  • MultiLine (classic)
  • StackedBar (classic)
  • Ranking Bars (classic)

Guides

  • Usage & config
  • Patterns & effects
  • Show / hide anything
  • Chart theming
  • Interactions
23 charts, built on the open-source expo-charts engineAll charts
Blocks

Screens

ChatProAn AI assistant chat screenCheckoutProA premium checkoutDashboardProA finance / analytics home screenInboxProA messaging inboxOnboardingProA minimal swipeable welcome flowProfileProAccount profile for any appSearch ResultsProA clean, monochrome search screenSettingsProAn iOS-style grouped settings screenSign InProOne configurable sign-in flow modelled on top mobile apps
9 complete screens — drop one in, wire your dataSee them on devices All blocks
Pricing
Docs3DTilt Card

Tilt Card

Scan with Expo Go

Tilt Card with all its variants, native in Expo Go. No account needed.

No Expo Go? Open in your browser

A card that leans toward your finger in 3D while a glare slides across it.

<TiltCard />4 variantsiOS · Android · Expo Go
9:41

Sunday 18 October

9:41

Installation#

One command copies the source into your app — you own the code and can change anything.

Usage#

Colours, radius and light / dark come from useKit(). Wrap your app in <KitProvider accent="#6366f1" radius={14}> to re-skin every component at once.

Props#

PropTypeDefault
children

Content (sets the card's size). Wrap parts in <TiltLayer depth> to float them.

ReactNode—
image

Photo that fills the card behind the content.

ImageSourcePropType—
shade

Darkens the bottom of the photo so white text stays readable (0–1).

number0.6
maxTilt

Tilt in degrees at the card's edge.

number12
pressScale

Scale while touched.

number1.03
glare

Light reflection that follows your finger. "holo" adds a rainbow foil sheen that shifts as the card leans.

"soft" | "sharp" | "holo" | "none""soft"
glareClip

Where the reflection may fall (card points) — for cards with cut-outs, so light never shows over a hole.

{ x, y, width, height }[]whole card
perspective

Depth of the effect — smaller is more dramatic.

number800
idle

Slowly sway while untouched.

booleanfalse
radius

Corner radius.

number20
shadow

Soft shadow under the card that shifts as it tilts.

booleantrue
disabled

Turn touch tilting off.

booleanfalse
onPress

Fires on a tap (not after a drag).

() => void—
accessibilityLabel

Label for screen readers.

string—
style

Style for the card (width, margins).

StyleProp<ViewStyle>—
TiltLayer.depth

How far a layer floats above the card (negative sinks below).

number0.5
TiltFoil

A holographic foil patch (seal, sticker, strip) whose rainbow shifts as the card leans.

{ children?, radius?, base?, style? }—
useTilt()

The card's live tilt values (native-driver friendly) for your own effects.

() => { tx, ty, press, width, height } | null—

Behaviour & accessibility#

Touch
Press and drag to tilt up to maxTilt degrees; the card scales by pressScale and springs flat on release. onPress fires on a tap, not after a drag.
Motion
The shadow slides away from the light and the glare (soft, sharp or holo) glides across. TiltLayer children float at their own depth, and idle makes it sway gently at rest.
Screen readers
Exposed as one element when it has onPress or accessibilityLabel; with onPress it is a button with an 'activate' action.

Source#

PreviousTicketNextWheel List

Did you like the content?

Coding agents

Chat apps

Other

9:41

Travel Card

Tilt Card

Manual installation
  1. Install dependencies

    terminal
    npx expo install react-native-svg
  2. Copy the files

    Create each file at the same path and paste its code. @/ points at your src/ folder (the default in new Expo apps). Skip any file you already have, like lib/kit.tsx.

    • src/components/ui/tilt-card.tsx636 lines
    • src/lib/kit.tsx220 lines

    Want to read it first? Every file is in Source below.

  3. Use it

    App.tsx
    import { TiltCard } from "@/components/ui/tilt-card";

    See Usage for a full example.

Example.tsx
import { TiltCard, TiltLayer } from '@/components/ui/tilt-card'; <TiltCard image={{ uri: photoUrl }} idle accessibilityLabel="Manarola, Cinque Terre" style={{ width: 270, height: 360 }}>  <TiltLayer depth={1}>    <PlaceCaption place={place} />  </TiltLayer></TiltCard>

Every variant above has a complete, runnable example in its Code tab.

src/components/ui/tilt-card.tsx
import React, { createContext, useContext, useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';import {  Animated,  Easing,  Image,  PanResponder,  StyleSheet,  View,  type ImageSourcePropType,  type LayoutChangeEvent,  type StyleProp,  type ViewStyle,} from 'react-native';import Svg, { Defs, LinearGradient, RadialGradient, Rect, Stop } from 'react-native-svg'; import { motion } from '@/lib/kit'; /** `holo` adds a rainbow foil sheen that shifts as the card tilts (always faintly visible). */export type TiltCardGlare = 'soft' | 'sharp' | 'holo' | 'none'; /** A rectangle in card points. */export interface TiltRect {  x: number;  y: number;  width: number;  height: number;} export interface TiltCardProps {  children?: React.ReactNode;  /** Photo that fills the card behind the content (`require()` or `{ uri }`). */  image?: ImageSourcePropType;  /** Darkens the bottom of `image` so white text on it stays readable: 0 off … 1 strong (default 0.6). */  shade?: number;  /** Maximum tilt in degrees at the card's edge (default 12). */  maxTilt?: number;  /** Scale while touched (default 1.03). */  pressScale?: number;  /** Light reflection that slides across the card as it tilts. */  glare?: TiltCardGlare;  /**   * Where the reflection may fall, in card points — for cards with cut-outs (ticket notches,   * perforated edges), so light never shows over a hole. Default: the whole card.   */  glareClip?: TiltRect[];  /** Depth of the 3D effect — smaller is more dramatic (default 800). */  perspective?: number;  /** Slowly sway on its own while untouched, so the card reads as 3D at rest. */  idle?: boolean;  /** Corner radius (default 20). */  radius?: number;  /** Soft shadow under the card that shifts as it tilts (default true). */  shadow?: boolean;  /**   * The part of the card that casts the shadow, in card points — e.g. leave out a ticket stub that's   * being torn off, so the plate never shows where it was. Default: the whole card.   */  shadowRect?: TiltRect;  /**   * Narrows the shadow sideways (1 = full width, 0 = none) — for content that turns edge-on inside   * the card, like a FlipCard mid-flip, so the shadow plate never shows past it.   */  shadowScaleX?: Animated.Value | Animated.AnimatedInterpolation<number>;  /** Turn touch tilting off (the card still renders, e.g. while a parent list scrolls). */  disabled?: boolean;  /** Called with a light tap (not after a drag). */  onPress?: () => void;  accessibilityLabel?: string;  /** Width, height, margins — applied to the card's outer box. */  style?: StyleProp<ViewStyle>;} export interface TiltLayerProps {  children?: React.ReactNode;  /**   * How far above the card this layer floats: 0 sits on the card, 1 floats well above it   * and slides furthest as the card tilts (default 0.5). Negative sinks below the surface.   */  depth?: number;  /** Layers fill the card by default; position content inside, or override here. */  style?: StyleProp<ViewStyle>;} export interface TiltState {  /** -1 … 1 across the card (left → right). */  tx: Animated.Value;  /** -1 … 1 down the card (top → bottom). */  ty: Animated.Value;  /** 0 at rest … 1 while touched. */  press: Animated.Value;  width: number;  height: number;} const Ctx = createContext<TiltState | null>(null); /** The surrounding TiltCard's live tilt (null outside one) — for your own layers and effects. */export function useTilt() {  return useContext(Ctx);} // Gradients are drawn small and scaled up: a smooth gradient looks the same at any size, and a// card-sized (or larger) SVG would cost megabytes of texture per card in a list.const PAINT = 96; // Six colour cycles across the paint, so a couple of bands cross the card like real foil. Cool// pinks, violets and cyans stay clean when blended over dark cards (yellow and green turn olive).const HOLO = Array.from({ length: 37 }, (_, i) => ['#ff6bd6', '#b38cff', '#6fb6ff', '#5ff0ff', '#a7ffe0', '#ffc2ef'][i % 6]); function GlarePaint({ variant }: { variant: 'soft' | 'sharp' }) {  const sharp = variant === 'sharp';  return (    <Svg width={PAINT} height={PAINT}>      <Defs>        <RadialGradient id={`tilt-glare-${variant}`} cx="50%" cy="50%" r="50%">          <Stop offset="0" stopColor="#ffffff" stopOpacity={sharp ? 0.85 : 0.6} />          <Stop offset={sharp ? '0.25' : '0.5'} stopColor="#ffffff" stopOpacity={0.1} />          <Stop offset="1" stopColor="#ffffff" stopOpacity={0} />        </RadialGradient>      </Defs>      <Rect width={PAINT} height={PAINT} fill={`url(#tilt-glare-${variant})`} />    </Svg>  );} function HoloPaint({ strength = 0.6 }: { strength?: number }) {  return (    <Svg width={PAINT} height={PAINT}>      <Defs>        <LinearGradient id={`tilt-holo-${Math.round(strength * 100)}`} x1="0" y1="0" x2="1" y2="1">          {HOLO.map((c, i) => (            <Stop key={i} offset={String(i / (HOLO.length - 1))} stopColor={c} stopOpacity={strength} />          ))}        </LinearGradient>      </Defs>      <Rect width={PAINT} height={PAINT} fill={`url(#tilt-holo-${Math.round(strength * 100)})`} />    </Svg>  );} /** The reflection (and holo sheen) for a card of size w × h, in card coordinates. */function Reflection({  variant,  w,  h,  tx,  ty,  press,  idle,}: {  variant: Exclude<TiltCardGlare, 'none'>;  w: number;  h: number;  tx: Animated.Value;  ty: Animated.Value;  press: Animated.Value;  idle: boolean;}) {  const G = Math.max(w, h) * 1.7;  const H = Math.max(w, h) * 2.6;  const box = {    position: 'absolute' as const,    width: PAINT,    height: PAINT,    left: (w - PAINT) / 2,    top: (h - PAINT) / 2,  };  const glareOpacity = press.interpolate({    inputRange: [0, 1],    outputRange: [idle ? 0.22 : 0, variant === 'sharp' ? 0.55 : 0.32],  });  return (    <>      {variant === 'holo' ? (        <Animated.View          pointerEvents="none"          style={{            ...box,            // Overlay blending tints the card like foil instead of washing it out.            mixBlendMode: 'overlay',            opacity: press.interpolate({              inputRange: [0, 1],              outputRange: [idle ? 0.26 : 0, 0.5],            }),            transform: [              // Travels further than the light, so the colours sweep as the card leans.              {                translateX: tx.interpolate({                  inputRange: [-1, 1],                  outputRange: [w * 0.9, -w * 0.9],                }),              },              {                translateY: ty.interpolate({                  inputRange: [-1, 1],                  outputRange: [h * 0.6, -h * 0.6],                }),              },              { scale: H / PAINT },            ],          }}        >          <HoloPaint strength={0.85} />        </Animated.View>      ) : null}      <Animated.View        pointerEvents="none"        style={{          ...box,          opacity: glareOpacity,          transform: [            // The highlight sits under the finger.            {              translateX: tx.interpolate({                inputRange: [-1, 1],                outputRange: [-w * 0.5, w * 0.5],              }),            },            {              translateY: ty.interpolate({                inputRange: [-1, 1],                outputRange: [-h * 0.5, h * 0.5],              }),            },            { scale: G / PAINT },          ],        }}      >        <GlarePaint variant={variant === 'sharp' ? 'sharp' : 'soft'} />      </Animated.View>    </>  );} /** * A card that tilts toward your finger in 3D: it lifts, its shadow slides away from the light, * and a reflection glides across it. Put content in `TiltLayer`s to make it float above the * surface. Native-driven transforms, no extra libraries. */export function TiltCard({  children,  image,  shade = 0.6,  maxTilt = 12,  pressScale = 1.03,  glare = 'soft',  glareClip,  perspective = 800,  idle = false,  radius = 20,  shadow = true,  shadowRect,  shadowScaleX,  disabled = false,  onPress,  accessibilityLabel,  style,}: TiltCardProps) {  const [size, setSize] = useState({ w: 0, h: 0 });  const sizeRef = useRef(size);  sizeRef.current = size;   // -1 … 1 across the card; the card leans toward the finger.  const tx = useRef(new Animated.Value(0)).current;  const ty = useRef(new Animated.Value(0)).current;  const press = useRef(new Animated.Value(0)).current;  const touching = useRef(false);  const origin = useRef({ x: 0, y: 0 });  const moved = useRef(false);  const viewRef = useRef<ComponentRef<typeof View>>(null);  const disabledRef = useRef(disabled);  disabledRef.current = disabled;   // Idle sway: a slow figure-eight while nobody is touching.  const idleLoop = useRef<Animated.CompositeAnimation | null>(null);  const startIdle = () => {    if (!idle) return;    const ease = Easing.inOut(Easing.sin);    const leg = (v: Animated.Value, to: number, ms: number) =>      Animated.timing(v, {        toValue: to,        duration: ms,        easing: ease,        useNativeDriver: true,      });    idleLoop.current = Animated.loop(      Animated.parallel([        Animated.sequence([leg(tx, 0.5, 2400), leg(tx, -0.5, 4800), leg(tx, 0, 2400)]),        Animated.sequence([leg(ty, -0.35, 1200), leg(ty, 0.35, 2400), leg(ty, -0.35, 2400), leg(ty, 0.35, 2400), leg(ty, 0, 1200)]),      ])    );    idleLoop.current.start();  };  useEffect(() => {    startIdle();    return () => idleLoop.current?.stop();    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [idle]);   const aim = (pageX: number, pageY: number) => {    const { w, h } = sizeRef.current;    if (!w || !h) return;    tx.setValue(Math.max(-1, Math.min(1, ((pageX - origin.current.x) / w) * 2 - 1)));    ty.setValue(Math.max(-1, Math.min(1, ((pageY - origin.current.y) / h) * 2 - 1)));  };   const release = (tapped: boolean) => {    touching.current = false;    if (tapped && !moved.current) onPressRef.current?.();    Animated.parallel([      Animated.spring(tx, {        toValue: 0,        ...motion.gentle,        useNativeDriver: true,      }),      Animated.spring(ty, {        toValue: 0,        ...motion.gentle,        useNativeDriver: true,      }),      Animated.spring(press, {        toValue: 0,        ...motion.gentle,        useNativeDriver: true,      }),    ]).start(({ finished }) => {      if (finished && !touching.current) startIdle();    });  };  // Read the latest onPress at release time, so the responder never has to be rebuilt mid-gesture.  const onPressRef = useRef(onPress);  onPressRef.current = onPress;  const releaseRef = useRef(release);  releaseRef.current = release;   const responder = useMemo(    () =>      PanResponder.create({        onStartShouldSetPanResponder: () => !disabledRef.current,        // Only a real drag takes the touch from a button inside the card — a tap's tiny jitter must not.        onMoveShouldSetPanResponder: (_e, g) => !disabledRef.current && Math.abs(g.dx) + Math.abs(g.dy) > 6,        // Let a parent ScrollView take over scrolls that start on the card (on Android the native        // scroll view can only do that when the card doesn't block it).        onPanResponderTerminationRequest: () => true,        onShouldBlockNativeResponder: () => false,        onPanResponderGrant: (e) => {          touching.current = true;          moved.current = false;          idleLoop.current?.stop();          const { pageX, pageY, locationX, locationY } = e.nativeEvent;          // locationX is relative to the touched child, so measure the card itself and correct          // once the measurement arrives.          origin.current = { x: pageX - locationX, y: pageY - locationY };          viewRef.current?.measure((_x, _y, _w, _h, px, py) => {            if (typeof px === 'number' && typeof py === 'number') origin.current = { x: px, y: py };          });          aim(pageX, pageY);          Animated.spring(press, {            toValue: 1,            ...motion.snappy,            useNativeDriver: true,          }).start();        },        onPanResponderMove: (e, g) => {          if (Math.abs(g.dx) + Math.abs(g.dy) > 6) moved.current = true;          aim(e.nativeEvent.pageX, e.nativeEvent.pageY);        },        onPanResponderRelease: () => releaseRef.current(true),        onPanResponderTerminate: () => releaseRef.current(false),        // React Native grants before asking the current responder to let go; when a child keeps its        // gesture (e.g. a ticket stub being torn), the grant is rejected and no release follows.        onPanResponderReject: () => releaseRef.current(false),      }),    // eslint-disable-next-line react-hooks/exhaustive-deps    []  );   const onLayout = (e: LayoutChangeEvent) => {    const { width, height } = e.nativeEvent.layout;    if (width !== size.w || height !== size.h) setSize({ w: width, h: height });  };   const rotateY = tx.interpolate({    inputRange: [-1, 1],    outputRange: [`${-maxTilt}deg`, `${maxTilt}deg`],  });  const rotateX = ty.interpolate({    inputRange: [-1, 1],    outputRange: [`${maxTilt}deg`, `${-maxTilt}deg`],  });  const scale = press.interpolate({    inputRange: [0, 1],    outputRange: [1, pressScale],  });   const ctx = useMemo(() => ({ tx, ty, press, width: size.w, height: size.h }), [tx, ty, press, size.w, size.h]);  const drift = Math.min(10, size.w * 0.03);  // The plate sits well inside the outline — far enough that it never shows through a notch or a  // see-through corner, even at full tilt; the shadow offset does the downward shift.  const body = shadowRect ?? { x: 0, y: 0, width: size.w, height: size.h };  const inset = {    x: Math.max(18, body.width * 0.08),    y: Math.max(20, body.height * 0.08),  };   const reflection = glare !== 'none' && size.w ? <Reflection variant={glare} w={size.w} h={size.h} tx={tx} ty={ty} press={press} idle={idle} /> : null;   return (    <View style={style} onLayout={onLayout}>      {/* Shadow plate: hidden under the card, its soft shadow drifts away from the light as the          card leans, and spreads when the card lifts. */}      {shadow && size.w ? (        <Animated.View          pointerEvents="none"          style={[            styles.plate,            {              ...(shadowRect                ? { left: body.x + inset.x, top: body.y + inset.y, width: Math.max(0, body.width - 2 * inset.x), height: Math.max(0, body.height - 2 * inset.y) }                : { left: inset.x, right: inset.x, top: inset.y, bottom: inset.y }),              borderRadius: radius,              opacity: press.interpolate({                inputRange: [0, 1],                outputRange: [0.75, 1],              }),              transform: [                {                  translateX: tx.interpolate({                    inputRange: [-1, 1],                    outputRange: [drift, -drift],                  }),                },                {                  scale: press.interpolate({                    inputRange: [0, 1],                    outputRange: [1, 1.035],                  }),                },                { scaleX: shadowScaleX ?? 1 },              ],            },          ]}        />      ) : null}      {/* The card sits in its own (non-collapsing) layer: iOS intersects sibling layers in 3D, so a          tilted card next to the flat shadow plate would be sliced by it along a diagonal. */}      {/* Android draws siblings by elevation, so the wrapper (not the card inside it) carries the          elevation that keeps it above the plate; with no background it casts no shadow itself. */}      <View collapsable={false} style={{ elevation: shadow ? 15 : 0 }}>        <Animated.View          ref={viewRef}          {...responder.panHandlers}          accessible={!!onPress || !!accessibilityLabel}          accessibilityRole={onPress ? 'button' : undefined}          accessibilityLabel={accessibilityLabel}          // Screen readers activate the card through an action, not synthesized touches.          accessibilityActions={onPress ? [{ name: 'activate' }] : undefined}          onAccessibilityAction={(e) => {            if (e.nativeEvent.actionName === 'activate') onPressRef.current?.();          }}          // elevation (no background → no extra shadow) keeps the card above its shadow plate on Android.          style={{            borderRadius: radius,            elevation: shadow ? 15 : 0,            transform: [{ perspective }, { rotateX }, { rotateY }, { scale }],          }}        >          <View style={[styles.clip, { borderRadius: radius }]}>            {image ? (              <>                <Image alt="" source={image} style={StyleSheet.absoluteFill} resizeMode="cover" accessibilityIgnoresInvertColors />                {shade > 0 ? (                  <Svg width="100%" height="100%" style={StyleSheet.absoluteFill} preserveAspectRatio="none" viewBox="0 0 1 1">                    <Defs>                      <LinearGradient id={`tilt-shade-${Math.round(shade * 100)}`} x1="0" y1="0" x2="0" y2="1">                        <Stop offset="0.35" stopColor="#000" stopOpacity={0} />                        <Stop offset="1" stopColor="#000" stopOpacity={shade} />                      </LinearGradient>                    </Defs>                    <Rect width="1" height="1" fill={`url(#tilt-shade-${Math.round(shade * 100)})`} />                  </Svg>                ) : null}              </>            ) : null}            <Ctx.Provider value={ctx}>{children}</Ctx.Provider>            {reflection && !glareClip ? reflection : null}            {reflection && glareClip              ? glareClip.map((r, i) => (                  // One window per rect; each holds the full-card reflection shifted back into place.                  <View key={i} pointerEvents="none" style={[styles.window, { left: r.x, top: r.y, width: r.width, height: r.height }]}>                    <View                      style={{                        position: 'absolute',                        left: -r.x,                        top: -r.y,                        width: size.w,                        height: size.h,                      }}                    >                      {reflection}                    </View>                  </View>                ))              : null}          </View>        </Animated.View>      </View>    </View>  );} /** * Content that floats above (or sinks below) a `TiltCard`'s surface: as the card tilts, a layer * slides further the higher it floats, and lifts a little when the card is pressed. */export function TiltLayer({ children, depth = 0.5, style }: TiltLayerProps) {  const ctx = useContext(Ctx);  if (!ctx) throw new Error('TiltLayer must be inside TiltCard');  const d = depth * 14;  return (    <Animated.View      pointerEvents="box-none"      style={[        StyleSheet.absoluteFill,        {          transform: [            {              translateX: ctx.tx.interpolate({                inputRange: [-1, 1],                outputRange: [-d, d],              }),            },            {              translateY: ctx.ty.interpolate({                inputRange: [-1, 1],                outputRange: [-d, d],              }),            },            {              scale: ctx.press.interpolate({                inputRange: [0, 1],                outputRange: [1, 1 + Math.max(0, depth) * 0.035],              }),            },          ],        },        style,      ]}    >      {children}    </Animated.View>  );} export interface TiltFoilProps {  children?: React.ReactNode;  /** Corner radius (default 8). */  radius?: number;  /** Base metal under the rainbow (default silver). */  base?: string;  style?: StyleProp<ViewStyle>;} /** * A holographic foil patch — a seal, a sticker, a security strip — whose rainbow shifts as the * surrounding TiltCard leans. Outside a TiltCard it shows a still foil. */export function TiltFoil({ children, radius = 8, base = '#d7dbe2', style }: TiltFoilProps) {  const ctx = useContext(Ctx);  const [size, setSize] = useState({ w: 0, h: 0 });  const S = Math.max(size.w, size.h) * 3.2;  const travel = Math.max(size.w, size.h) * 0.9;  return (    <View      onLayout={(e) => {        const { width, height } = e.nativeEvent.layout;        if (width !== size.w || height !== size.h) setSize({ w: width, h: height });      }}      style={[{ borderRadius: radius, overflow: 'hidden', backgroundColor: base }, style]}    >      {size.w ? (        <Animated.View          pointerEvents="none"          style={{            position: 'absolute',            width: PAINT,            height: PAINT,            left: (size.w - PAINT) / 2,            top: (size.h - PAINT) / 2,            transform: [              {                translateX: ctx                  ? ctx.tx.interpolate({                      inputRange: [-1, 1],                      outputRange: [travel, -travel],                    })                  : 0,              },              {                translateY: ctx                  ? ctx.ty.interpolate({                      inputRange: [-1, 1],                      outputRange: [travel * 0.6, -travel * 0.6],                    })                  : 0,              },              { scale: S / PAINT },            ],          }}        >          <HoloPaint strength={0.85} />        </Animated.View>      ) : null}      {children}    </View>  );} const styles = StyleSheet.create({  plate: {    position: 'absolute',    top: 0,    left: 0,    right: 0,    bottom: 0,    backgroundColor: '#000',    shadowColor: '#000',    shadowOpacity: 0.45,    shadowRadius: 24,    shadowOffset: { width: 0, height: 16 },    elevation: 14,  },  clip: { overflow: 'hidden' },  window: { position: 'absolute', overflow: 'hidden' },});