cards
Receipt Unfold
A compact receipt that unfolds to reveal line items, adjustments, and the final total.
iOSAndroidExpo Go
reanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-reanimatedInstall tailwind variants
pnpm add tailwind-variantsUsage
ReceiptUnfoldExample.tsximport { ReceiptUnfold } from "@animations/ui/components/cards/receipt-unfold/uniwind";const items = [ { id: "plan", label: "Pro plan", value: "$18.00" }, { id: "tax", label: "Tax", value: "$1.44" },];export function ReceiptUnfoldExample() { return ( <ReceiptUnfold classNames={{ indicator: "bg-violet-100" }} items={items} title="Payment receipt" total="$19.44" /> );}receipt-unfold/config.tsexport const RECEIPT_UNFOLD_CONFIG = { spring: { damping: 19, mass: 0.8, stiffness: 190, },} as const;receipt-unfold/types.tsexport type ReceiptUnfoldItem = { id: string; label: string; value: string;};export type ReceiptUnfoldBaseProps = { accessibilityLabel?: string; defaultExpanded?: boolean; disabled?: boolean; expanded?: boolean; items: readonly ReceiptUnfoldItem[]; onExpandedChange?: (expanded: boolean) => void; subtitle?: string; title: string; total: string; totalLabel?: string;};receipt-unfold/uniwind/index.tsximport { Feather } from "@expo/vector-icons";import { Pressable, Text, View } from "react-native";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import type { ReceiptUnfoldBaseProps } from "../types";import { useReceiptUnfold } from "../use-receipt-unfold";import { receiptUnfoldVariants, type ReceiptUnfoldSlot } from "./variants";export type ReceiptUnfoldProps = ReceiptUnfoldBaseProps & { className?: string; classNames?: SlotsToClasses<ReceiptUnfoldSlot>;};export function ReceiptUnfold({ accessibilityLabel, className, classNames, defaultExpanded, disabled, expanded, items, onExpandedChange, subtitle, title, total, totalLabel = "Total",}: ReceiptUnfoldProps) { const { detailsEntering, detailsExiting, indicatorAnimatedStyle, isExpanded, layoutTransition, toggle } = useReceiptUnfold({ defaultExpanded, disabled, expanded, onExpandedChange }); const slots = receiptUnfoldVariants({ disabled }); return ( <Animated.View className={slots.container({ className: [className, classNames?.container] })} layout={layoutTransition}> <Pressable accessibilityLabel={accessibilityLabel ?? title} accessibilityRole="button" accessibilityState={{ disabled, expanded: isExpanded }} className={slots.header({ className: classNames?.header })} disabled={disabled} onPress={toggle}> <View> <Text className={slots.title({ className: classNames?.title })}>{title}</Text> {subtitle && <Text className={slots.caption({ className: classNames?.caption })}>{subtitle}</Text>} </View> <Animated.View className={slots.indicator({ className: classNames?.indicator })} style={indicatorAnimatedStyle}> <Feather color="#171713" name="chevron-down" size={18} /> </Animated.View> </Pressable> {isExpanded && ( <Animated.View className={slots.details({ className: classNames?.details })} entering={detailsEntering} exiting={detailsExiting}> <View className={slots.divider({ className: classNames?.divider })} /> {items.map((item) => ( <View className={slots.item({ className: classNames?.item })} key={item.id}> <Text className={slots.itemLabel({ className: classNames?.itemLabel })}>{item.label}</Text> <Text className={slots.itemValue({ className: classNames?.itemValue })}>{item.value}</Text> </View> ))} <View className={slots.totalRow({ className: classNames?.totalRow })}> <Text className={slots.totalLabel({ className: classNames?.totalLabel })}>{totalLabel}</Text> <Text className={slots.total({ className: classNames?.total })}>{total}</Text> </View> </Animated.View> )} </Animated.View> );}receipt-unfold/uniwind/variants.tsimport { tv } from "tailwind-variants";export const receiptUnfoldSlots = { caption: "text-xs font-semibold text-[#85847d]", container: "w-full overflow-hidden rounded-3xl border border-[#deddd5] bg-white", details: "gap-3 px-5 pb-5", divider: "mb-1 border-t border-dashed border-[#d9d8d0]", header: "min-h-19.5 flex-row items-center justify-between px-5 py-4", indicator: "size-7.5 items-center justify-center rounded-[14px] bg-[#efefe9]", item: "flex-row justify-between", itemLabel: "text-[13px] font-semibold text-[#77776f]", itemValue: "text-[13px] font-bold text-[#272722]", title: "text-[17px] font-black text-[#171713]", total: "text-[19px] font-black text-[#171713]", totalLabel: "text-[11px] font-extrabold uppercase tracking-[1px] text-[#77776f]", totalRow: "flex-row items-end justify-between pt-0.5",} as const;export type ReceiptUnfoldSlot = keyof typeof receiptUnfoldSlots;export const receiptUnfoldVariants = tv({ slots: receiptUnfoldSlots, variants: { disabled: { true: { container: "opacity-[0.45]" }, }, },});receipt-unfold/use-receipt-unfold.tsimport { useCallback, useState } from "react";import { FadeInDown, FadeOutUp, LinearTransition, useAnimatedStyle, useDerivedValue, useReducedMotion, withSpring,} from "react-native-reanimated";import { RECEIPT_UNFOLD_CONFIG } from "./config";import type { ReceiptUnfoldBaseProps } from "./types";const layoutTransition = LinearTransition.springify() .damping(RECEIPT_UNFOLD_CONFIG.spring.damping) .mass(RECEIPT_UNFOLD_CONFIG.spring.mass) .stiffness(RECEIPT_UNFOLD_CONFIG.spring.stiffness);const detailsEntering = FadeInDown.duration(180);const detailsExiting = FadeOutUp.duration(160);export function useReceiptUnfold({ defaultExpanded = false, disabled = false, expanded, onExpandedChange,}: Pick< ReceiptUnfoldBaseProps, | "defaultExpanded" | "disabled" | "expanded" | "onExpandedChange">) { const [internalExpanded, setInternalExpanded] = useState(defaultExpanded); const isControlled = expanded !== undefined; const isExpanded = expanded ?? internalExpanded; const reducedMotion = useReducedMotion(); const rotation = useDerivedValue(() => { const nextRotation = isExpanded ? 180 : 0; return reducedMotion ? nextRotation : withSpring( nextRotation, RECEIPT_UNFOLD_CONFIG.spring, ); }); const indicatorAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.get()}deg` }], })); const toggle = useCallback(() => { if (disabled) { return; } const nextExpanded = !isExpanded; if (!isControlled) { setInternalExpanded(nextExpanded); } onExpandedChange?.(nextExpanded); }, [ disabled, isControlled, isExpanded, onExpandedChange, ]); return { detailsEntering: reducedMotion ? undefined : detailsEntering, detailsExiting: reducedMotion ? undefined : detailsExiting, indicatorAnimatedStyle, isExpanded, layoutTransition: reducedMotion ? undefined : layoutTransition, toggle, };}