Veltrix

cards

Receipt Unfold

A compact receipt that unfolds to reveal line items, adjustments, and the final total.

AppleiOSAndroidAndroidExpoExpo Go
reanimated
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add react-native-reanimated

Install tailwind variants

pnpm add tailwind-variants

Usage

ReceiptUnfoldExample.tsx
import { 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"    />  );}

Component files

5 files

receipt-unfold/config.ts
export const RECEIPT_UNFOLD_CONFIG = {  spring: {    damping: 19,    mass: 0.8,    stiffness: 190,  },} as const;
receipt-unfold/types.ts
export 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.tsx
import { 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.ts
import { 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.ts
import { 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,  };}

API reference

Generated directly from the exported component props.

ReceiptUnfold