{"slug":"card","name":"Card","description":"Composable surfaces, selectable with press feedback.","group":"Actions","detail_url":"https://beui.dev/native/r/card","page_url":"https://beui.dev/react-native/card","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/card?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/card","libraries":["react","react-native","react-native-reanimated","uniwind"],"install":["npx expo install react-native-reanimated react-native-worklets","npm install uniwind tailwindcss"],"setup":["Quickest: run `npx beui-native add <slug>` from the app's root. It writes the files below and installs the libraries.","Copy the files into the app and keep their folder layout: components import the shared files by relative path.","Add the theme tokens once, imported after Tailwind and Uniwind in the CSS file Uniwind reads.","Wrap the app in GestureHandlerRootView if it uses react-native-gesture-handler and isn't already.","Works in Expo and in bare React Native. Every component honors the system Reduce Motion setting."],"files":[{"path":"src/components/card/card.tsx","content":"import {\n  Children,\n  createContext,\n  isValidElement,\n  type ReactNode,\n  type Ref,\n  useContext,\n  useEffect,\n} from \"react\";\nimport {\n  Pressable,\n  type PressableProps,\n  StyleSheet,\n  Text,\n  type TextProps,\n  View,\n  type ViewProps,\n} from \"react-native\";\nimport Animated, {\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { motion } from \"../../styles/motion\";\n\nexport type CardVariant = \"outlined\" | \"muted\";\nexport type CardSize = \"default\" | \"compact\";\n\nconst CardSizeContext = createContext<CardSize>(\"default\");\n\nconst VARIANTS: Record<CardVariant, string> = {\n  outlined: \"border-border bg-background\",\n  muted: \"border-transparent bg-surface\",\n};\n// The root owns the vertical padding and the gap between parts; each part pads\n// itself horizontally, so any part can be left out without others losing edges.\nconst ROOT_SIZES: Record<CardSize, string> = {\n  default: \"gap-6 py-6\",\n  compact: \"gap-4 py-4\",\n};\nconst INSETS: Record<CardSize, string> = {\n  default: \"px-6\",\n  compact: \"px-4\",\n};\n\nconst RADIUS = 16;\nconst PRESS_SCALE = 0.985;\nconst SELECT_FADE = { duration: 200, reduceMotion: motion.fade.reduceMotion };\n\nexport type CardProps = Omit<PressableProps, \"children\" | \"style\"> & {\n  /** Defaults to \"outlined\". */\n  variant?: CardVariant;\n  /** Spacing for every part. Defaults to \"default\". */\n  size?: CardSize;\n  /**\n   * Makes the whole card one touch target with press feedback. Give it an\n   * accessibilityRole that says what pressing does, such as \"button\" or \"checkbox\".\n   */\n  onPress?: PressableProps[\"onPress\"];\n  /** Draws the chosen outline and reports the selected state. */\n  selected?: boolean;\n  /** Disables press motion independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n  style?: ViewProps[\"style\"];\n  children?: ReactNode;\n  ref?: Ref<View>;\n};\n\n/** A surface built from parts: CardHeader, CardContent and CardFooter. */\nexport function Card({\n  variant = \"outlined\",\n  size = \"default\",\n  onPress,\n  selected = false,\n  disableAnimation = false,\n  disabled,\n  className = \"\",\n  style,\n  children,\n  accessibilityState,\n  onPressIn,\n  onPressOut,\n  ref,\n  ...props\n}: CardProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const animate = !reducedMotion && !disableAnimation;\n  const interactive = onPress !== undefined;\n  const { backgroundColor: ringColor } = useResolveClassNames(\"bg-primary\");\n\n  const scale = useSharedValue(1);\n  const pressed = useSharedValue(0);\n  const selection = useSharedValue(selected ? 1 : 0);\n\n  useEffect(() => {\n    const target = selected ? 1 : 0;\n    selection.value = animate ? withTiming(target, SELECT_FADE) : target;\n  }, [animate, selected, selection]);\n\n  const pressStyle = useAnimatedStyle(\n    () => ({\n      transform: [{ scale: scale.value }],\n      // Without scale, pressing still needs visible feedback.\n      opacity: animate ? 1 : 1 - 0.15 * pressed.value,\n    }),\n    [animate],\n  );\n  const ringStyle = useAnimatedStyle(() => ({ opacity: selection.value }));\n\n  const surface = `rounded-2xl border ${VARIANTS[variant]} ${ROOT_SIZES[size]} ${disabled ? \"opacity-50\" : \"\"} ${className}`;\n  const ring = (\n    <Animated.View\n      style={[styles.ring, { borderColor: ringColor }, ringStyle]}\n    />\n  );\n\n  if (!interactive) {\n    return (\n      <CardSizeContext.Provider value={size}>\n        <View\n          {...(props as ViewProps)}\n          ref={ref}\n          className={surface}\n          style={style}\n        >\n          {children}\n          {ring}\n        </View>\n      </CardSizeContext.Provider>\n    );\n  }\n\n  return (\n    <CardSizeContext.Provider value={size}>\n      <Animated.View style={[styles.pressFrame, pressStyle]}>\n        <Pressable\n          {...props}\n          ref={ref}\n          accessible\n          disabled={disabled}\n          accessibilityState={{\n            ...accessibilityState,\n            selected,\n            disabled: disabled ?? false,\n          }}\n          className={surface}\n          style={style}\n          onPress={onPress}\n          onPressIn={(event) => {\n            pressed.value = 1;\n            if (animate) scale.value = withSpring(PRESS_SCALE, motion.press);\n            onPressIn?.(event);\n          }}\n          onPressOut={(event) => {\n            pressed.value = 0;\n            if (animate) scale.value = withSpring(1, motion.press);\n            onPressOut?.(event);\n          }}\n        >\n          {children}\n          {ring}\n        </Pressable>\n      </Animated.View>\n    </CardSizeContext.Provider>\n  );\n}\n\n/**\n * Title and description, with an optional CardAction pinned to the top\n * trailing corner.\n */\nexport function CardHeader({\n  className = \"\",\n  children,\n  ...props\n}: ViewProps & {\n  className?: string;\n}) {\n  const size = useContext(CardSizeContext);\n  const items = Children.toArray(children);\n  const actions = items.filter(\n    (child) => isValidElement(child) && child.type === CardAction,\n  );\n  const rest = items.filter((child) => !actions.includes(child));\n\n  return (\n    <View\n      {...props}\n      className={`flex-row items-start gap-4 ${INSETS[size]} ${className}`}\n    >\n      <View className=\"flex-1 gap-1.5\">{rest}</View>\n      {actions}\n    </View>\n  );\n}\n\nexport function CardTitle({\n  className = \"\",\n  heading = false,\n  ...props\n}: TextProps & {\n  className?: string;\n  /** Reports the title as a heading, for cards that start a section. */\n  heading?: boolean;\n}) {\n  return (\n    <Text\n      {...props}\n      accessibilityRole={heading ? \"header\" : props.accessibilityRole}\n      className={`text-[15px] leading-snug font-semibold text-foreground ${className}`}\n    />\n  );\n}\n\nexport function CardDescription({\n  className = \"\",\n  ...props\n}: TextProps & { className?: string }) {\n  return (\n    <Text\n      {...props}\n      className={`text-sm leading-relaxed text-muted ${className}`}\n    />\n  );\n}\n\n/** Sits beside the title and description inside CardHeader. */\nexport function CardAction({\n  className = \"\",\n  ...props\n}: ViewProps & { className?: string }) {\n  return <View {...props} className={`shrink-0 ${className}`} />;\n}\n\nexport function CardContent({\n  className = \"\",\n  ...props\n}: ViewProps & { className?: string }) {\n  const size = useContext(CardSizeContext);\n  return <View {...props} className={`${INSETS[size]} ${className}`} />;\n}\n\nexport function CardFooter({\n  className = \"\",\n  ...props\n}: ViewProps & { className?: string }) {\n  const size = useContext(CardSizeContext);\n  return (\n    <View\n      {...props}\n      className={`flex-row items-center gap-2 ${INSETS[size]} ${className}`}\n    />\n  );\n}\n\nconst styles = StyleSheet.create({\n  pressFrame: { alignSelf: \"stretch\" },\n  // The selected outline: 1 point over the card's border and 1 point outside\n  // it. Offsets count from inside the 1-point border.\n  ring: {\n    position: \"absolute\",\n    top: -2,\n    left: -2,\n    right: -2,\n    bottom: -2,\n    borderWidth: 2,\n    borderRadius: RADIUS + 1,\n    pointerEvents: \"none\",\n  },\n});\n","type":"component"},{"path":"src/components/card/index.ts","content":"export type { CardProps, CardSize, CardVariant } from \"./card\";\nexport {\n  Card,\n  CardAction,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"./card\";\n","type":"component"},{"path":"src/hooks/use-reduced-motion.ts","content":"import { useEffect, useState } from \"react\";\nimport { AccessibilityInfo } from \"react-native\";\nimport { useReducedMotion } from \"react-native-reanimated\";\n\n/** Reanimated supplies a synchronous initial value; native events keep it current. */\nexport function useReducedMotionPreference() {\n  const initialValue = useReducedMotion();\n  const [reducedMotion, setReducedMotion] = useState(initialValue);\n\n  useEffect(() => {\n    let active = true;\n    let receivedEvent = false;\n    const subscription = AccessibilityInfo.addEventListener(\n      \"reduceMotionChanged\",\n      (value) => {\n        receivedEvent = true;\n        setReducedMotion(value);\n      },\n    );\n    void AccessibilityInfo.isReduceMotionEnabled().then((value) => {\n      if (active && !receivedEvent) setReducedMotion(value);\n    });\n    return () => {\n      active = false;\n      subscription.remove();\n    };\n  }, []);\n\n  return reducedMotion;\n}\n","type":"shared"},{"path":"src/styles/motion.ts","content":"import { Easing, ReduceMotion } from \"react-native-reanimated\";\n\n// Mirrors beUI's lib/ease.ts so native motion matches the web library.\n\nexport const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);\nexport const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Content swaps: label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Shared-layout glides: pills, indicators and panels moving between positions. */\nexport const SPRING_LAYOUT = {\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Dragged handles and fills; critically damped so values never rebound off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\nexport const motion = {\n  press: SPRING_PRESS,\n  swap: SPRING_SWAP,\n  layout: SPRING_LAYOUT,\n  glide: SPRING_GLIDE,\n  exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },\n  fade: { duration: 150, reduceMotion: ReduceMotion.System },\n} as const;\n","type":"shared"},{"path":"src/styles/theme.css","content":"/* Matches the default beUI palette in ui-components/lib/themes.ts.\n   Native surface maps to card; muted maps to muted-foreground. */\n@layer theme {\n  :root {\n    @variant light {\n      --color-background: oklch(99% 0 0);\n      --color-surface: oklch(97% 0 0);\n      --color-foreground: oklch(15% 0 0);\n      --color-muted: oklch(50% 0 0);\n      --color-border: oklch(15% 0 0 / 0.06);\n      --color-border-strong: oklch(15% 0 0 / 0.16);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #dc2626;\n      --color-success: #059669;\n      --color-warning: #d97706;\n    }\n  }\n\n  :root {\n    @variant dark {\n      --color-background: #151515;\n      --color-surface: #1c1c1c;\n      --color-foreground: oklch(96% 0 0);\n      --color-muted: oklch(62% 0 0);\n      --color-border: rgb(255 255 255 / 0.05);\n      --color-border-strong: rgb(255 255 255 / 0.14);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #f87171;\n      --color-success: #34d399;\n      --color-warning: #fbbf24;\n    }\n  }\n}\n","type":"theme"}]}