Dynamic Credit Card Form
An interactive credit card payment form with real-time card brand detection, animated visual feedback, and comprehensive validation for secure payment processing.
react-nativeexpopayment-formcredit-cardform-validationreanimatedanimationbrand-detectionsecure-inputui-componentluhn-algorithm
Step 1: Install Dependencies
Expo gradient and react native svg all from swm
npx expo install react-native-svg expo-linear-gradient
Step 2: Add input validation functions
We are using luhn-algorithm to verify the card number
validateInfo.ts
/**
* Luhn algorithm in JavaScript: validate credit card number supplied as string of numbers
* @author ShirtlessKirk. Copyright (c) 2012.
* @license WTFPL (http://www.wtfpl.net/txt/copying)
*/
export const luhnChk = (function (arr) {
return function (ccNum: string): boolean { // Added type annotation for ccNum and return type
var
len = ccNum.length,
bit = 1,
sum = 0,
val;
while (len) {
val = parseInt(ccNum.charAt(--len), 10);
// Added a check for NaN in case ccNum contains non-numeric characters
if (isNaN(val)) {
return false; // Or handle error appropriately
}
sum += (bit ^= 1) ? arr[val] : val;
}
return sum !== 0 && sum % 10 === 0;
};
}([0, 2, 4, 6, 8, 1, 3, 5, 7, 9]));
export const validateExpiryDate = (expiry: string) => {
if (!/^\d{2}\/\d{2}$/.test(expiry)) return false;
const [monthStr, yearStr] = expiry.split('/');
const month = parseInt(monthStr, 10);
const year = parseInt('20' + yearStr, 10); // Converts '25' -> 2025
if (isNaN(month) || isNaN(year)) return false;
if (month < 1 || month > 12) return false;
const now = new Date();
const expiryDate = new Date(year, month); // set to first day of next month
return expiryDate > now;
};
Step 3: Add the SmoothBorderInputText from Components
Usage
CardDetailsFormPage.tsx
import React, { useEffect, useState } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
SafeAreaView,
KeyboardAvoidingView,
Platform,
useWindowDimensions,
Alert,
ActivityIndicator,
} from "react-native";
import { useAppColors } from "@/hooks/useAppColors";
import AntDesign from "@expo/vector-icons/AntDesign";
import Ionicons from "@expo/vector-icons/Ionicons";
import SmoothBorderTextInput from "@/components/ui/textInput/SmoothBorderTextInput";
import Animated, {
Easing,
ReduceMotion,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { LinearGradient } from "expo-linear-gradient";
import Svg, { Path } from "react-native-svg";
import { luhnChk, validateExpiryDate } from "@/utils/validateInfo";
const DEFAULT_GRADIENT: [string, string] = ["#CDCDCD", "#A0A0A0"]; // Light Grey to Darker Grey
const VISA_GRADIENT: [string, string] = ["#1A1F71", "#6366F1"]; // Dark Blue to Lighter Blue/Purple
const MASTERCARD_GRADIENT: [string, string] = ["#EB001B", "#FF5F00"]; // Red to Orange
const AMEX_GRADIENT: [string, string] = ["#006FCF", "#00A4E0"]; // Blue to Lighter Blue
const CARD_HEIGHT = 225;
type FormField = "cardName" | "cardNumber" | "expiryDate" | "cvv";
export default function CardDetailsFormPage() {
const { width: screenWidth } = useWindowDimensions();
const colors = useAppColors();
const motion = ReduceMotion.Never;
const [activeGradient, setActiveGradient] = useState<[string, string]>(DEFAULT_GRADIENT);
const [ActiveLogoComponent, setActiveLogoComponent] = useState(() => <></>); // Initialize
const [isLoading, setIsLoading] = useState(false);
const cardVisualsOpacity = useSharedValue(1); // Opacity for the current card visuals
// Form state
const [formData, setFormData] = useState({
cardName: "",
cardNumber: "",
expiryDate: "",
cvv: "",
});
// Error state
const [errors, setErrors] = useState({
cardName: false,
cardNumber: false,
expiryDate: false,
cvv: false,
});
// Handle input changes
const handleChange = (field: FormField, value: string) => {
// Format card number with spaces
if (field === "cardNumber") {
value = value
.replace(/[^0-9]/g, "")
.replace(/\s/g, "")
.replace(/(.{4})/g, "$1 ")
.trim();
}
// Format expiry date with slash
if (field === "expiryDate") {
if (value.length === 2 && formData.expiryDate.length === 1) {
value = value + "/";
}
value = value.replace(/[^0-9/]/g, "");
}
// Limit CVV to 3-4 digits
if (field === "cvv") {
value = value.replace(/[^0-9]/g, "").substring(0, 4);
}
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error when typing
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: false }));
}
};
// Validate form
const validateForm = () => {
const newErrors = {
cardName: !formData.cardName,
cardNumber: !luhnChk(formData.cardNumber.replace(/\s/g, "")),
expiryDate: !validateExpiryDate(formData.expiryDate),
cvv: !/^\d{3,4}$/.test(formData.cvv),
};
setErrors(newErrors);
return !Object.values(newErrors).some((error) => error);
};
// Handle form submission
const handleSubmit = () => {
console.log(formData.cardNumber);
if (validateForm()) {
setIsLoading(true);
// Simulate API call with setTimeout
setTimeout(() => {
console.log("Payment submitted:", formData);
Alert.alert("Success", "Payment processed successfully!", [
{
text: "OK",
onPress: () => {
// Clear form data
setFormData({
cardName: "",
cardNumber: "",
expiryDate: "",
cvv: "",
});
setIsLoading(false);
},
},
]);
}, 1500);
}
};
// Get card brand logo based on first digits
const getCardBrandDetails = () => {
const number = formData.cardNumber.replace(/\s/g, "");
if (!number) return { name: "CARD", gradient: DEFAULT_GRADIENT, Logo: () => <></> };
if (number.startsWith("4")) {
// Visa
return { name: "VISA", gradient: VISA_GRADIENT, Logo: VisaIcon };
} else if (/^5[1-5]/.test(number)) {
// Mastercard
return { name: "Mastercard", gradient: MASTERCARD_GRADIENT, Logo: MastercardIcon };
} else if (/^3[47]/.test(number)) {
// Amex
return { name: "American Express", gradient: AMEX_GRADIENT, Logo: AmexIcon };
}
return { name: "CARD", gradient: DEFAULT_GRADIENT, Logo: () => <></> };
};
useEffect(() => {
const newDetails = getCardBrandDetails();
// Only proceed if the actual brand (determined by name or gradient) changes
if (newDetails.gradient[0] !== activeGradient[0] || newDetails.gradient[1] !== activeGradient[1]) {
const animConfigOut = { duration: 200, easing: Easing.out(Easing.ease), reduceMotion: motion }; // Faster fade out
const animConfigIn = { duration: 300, easing: Easing.in(Easing.ease), reduceMotion: motion }; // Slightly slower fade in
// 1. Fade out current visuals
cardVisualsOpacity.value = withTiming(0, animConfigOut, (finished) => {
if (finished) {
// 2. Update state for gradient and logo (this happens on JS thread)
// This will cause a re-render with the new gradient/logo, but they are still at opacity 0.
runOnJS(setActiveGradient)(newDetails.gradient);
runOnJS(setActiveLogoComponent)(newDetails.Logo);
// 3. Fade in new visuals (this starts on UI thread after state update is processed)
cardVisualsOpacity.value = withTiming(1, animConfigIn);
}
});
}
}, [formData.cardNumber]);
const animatedCardVisualsStyle = useAnimatedStyle(() => ({
opacity: cardVisualsOpacity.value,
}));
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : "height"} style={styles.keyboardAvoid}>
<ScrollView contentContainerStyle={styles.scrollContainer} showsVerticalScrollIndicator={false}>
<View style={styles.formHeader}>
<View style={styles.securePaymentRow}>
<Ionicons name="shield-checkmark" size={22} color={colors.SuccessfulNormal} />
<Text style={[styles.securePaymentText, { color: colors.SuccessfulNormal }]}>Secure Payment</Text>
</View>
<Text style={[styles.formTitle, { color: colors.Neutral700 }]}>Payment Details</Text>
<Text style={[styles.formSubtitle, { color: colors.Neutral300 }]}>
Complete your purchase by providing your payment details
</Text>
</View>
<View style={[styles.cardIllustration]}>
<View style={[styles.cardPreviewContainer, { width: screenWidth - 32 }]}>
{/* Current Gradient (fades in) */}
<Animated.View style={[StyleSheet.absoluteFill, animatedCardVisualsStyle]}>
<LinearGradient
colors={activeGradient}
style={styles.gradientBase}
start={{ x: 0.0, y: 0.0 }}
end={{ x: 1.0, y: 1.0 }}
/>
</Animated.View>
{/* Card Content Overlay - always visible on top */}
<View style={styles.cardContentOverlay}>
<View style={styles.cardHeaderTop}>
<Chip />
<Animated.View style={[animatedCardVisualsStyle]}>{ActiveLogoComponent}</Animated.View>
</View>
<Text style={styles.cardNumberPreview}>{formData.cardNumber || "•••• •••• •••• ••••"}</Text>
<View style={styles.cardDetails}>
<View>
<Text style={styles.cardDetailLabel}>CARD HOLDER</Text>
<Text style={styles.cardDetailValue} numberOfLines={1}>
{formData.cardName.toUpperCase() || "YOUR NAME"}
</Text>
</View>
<View>
<Text style={styles.cardDetailLabel}>EXPIRES</Text>
<Text style={styles.cardDetailValue}>{formData.expiryDate || "MM/YY"}</Text>
</View>
</View>
</View>
</View>
</View>
<View style={styles.formContainer}>
{/* Card Number */}
<SmoothBorderTextInput
label="Card Number"
placeholder="1234 5678 9012 3456"
value={formData.cardNumber}
onChangeText={(text) => handleChange("cardNumber", text)}
labelColor={colors.Neutral500}
valueColor={colors.Neutral700}
isFocusBorderColor={colors.AuxColorTwo}
isBlurBorderColor={colors.Neutral100}
isBlurValueBorderColor={colors.SuccessfulNormal}
isError={errors.cardNumber}
errorMessage="Enter a valid card number"
keyboardType="number-pad"
maxLength={19} // 16 digits 3 spaces
reduceMotion="never"
/>
{/* Row for Expiry and CVV */}
<View style={styles.rowInputs}>
<View style={styles.halfInput}>
<SmoothBorderTextInput
label="Expiry Date"
placeholder="MM/YY"
value={formData.expiryDate}
onChangeText={(text) => handleChange("expiryDate", text)}
labelColor={colors.Neutral500}
valueColor={colors.Neutral700}
isFocusBorderColor={colors.AuxColorThree}
isBlurBorderColor={colors.Neutral100}
isBlurValueBorderColor={colors.SuccessfulNormal}
isError={errors.expiryDate}
errorMessage="Invalid date"
keyboardType="number-pad"
maxLength={5} // MM/YY
reduceMotion="never"
/>
</View>
<View style={styles.halfInput}>
<SmoothBorderTextInput
label="CVV"
placeholder="123"
value={formData.cvv}
onChangeText={(text) => handleChange("cvv", text)}
labelColor={colors.Neutral500}
valueColor={colors.Neutral700}
isFocusBorderColor={colors.AuxColorThree}
isBlurBorderColor={colors.Neutral100}
isBlurValueBorderColor={colors.SuccessfulNormal}
isError={errors.cvv}
errorMessage="Invalid CVV"
keyboardType="number-pad"
maxLength={4}
secureTextEntry
reduceMotion="never"
/>
</View>
</View>
{/* Card Name */}
<SmoothBorderTextInput
label="Cardholder Name"
placeholder="Name on card"
value={formData.cardName}
onChangeText={(text) => handleChange("cardName", text)}
labelColor={colors.Neutral500}
valueColor={colors.Neutral700}
isFocusBorderColor={colors.PrimaryNormal}
isBlurBorderColor={colors.Neutral100}
isBlurValueBorderColor={colors.SuccessfulNormal}
startIcon={
<AntDesign name="user" size={18} style={{ left: 12, marginRight: 4 }} color={colors.Neutral300} />
}
isError={errors.cardName}
errorMessage="Cardholder name is required"
autoCapitalize="words"
/>
<TouchableOpacity
style={[styles.payButton, { opacity: isLoading ? 0.5 : 1 }]}
onPress={handleSubmit}
activeOpacity={0.8}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color={colors.Neutral500} />
) : (
<Text style={styles.payButtonText}>Pay Now</Text>
)}
</TouchableOpacity>
<View style={styles.securityNote}>
<Ionicons name="lock-closed" size={16} color={colors.Neutral300} />
<Text style={[styles.securityText, { color: colors.Neutral300 }]}>
Your data is encrypted and secure. We respect your privacy.
</Text>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
keyboardAvoid: {
flex: 1,
},
scrollContainer: {
flexGrow: 1,
padding: 20,
},
formHeader: {
marginBottom: 24,
},
securePaymentRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
securePaymentText: {
fontSize: 14,
fontWeight: "600",
marginLeft: 6,
},
formTitle: {
fontSize: 26,
fontWeight: "700",
marginBottom: 8,
},
formSubtitle: {
fontSize: 15,
lineHeight: 22,
},
cardIllustration: {
alignItems: "center",
marginVertical: 20,
},
cardPreview: {
height: CARD_HEIGHT,
padding: 24,
},
NumberPreview: {
color: "#FFFFFF",
fontSize: 22,
fontWeight: "600",
letterSpacing: 2,
marginBottom: 30,
},
cardDetails: {
flexDirection: "row",
justifyContent: "space-between",
},
cardDetailLabel: {
color: "rgba(255, 255, 255, 0.7)",
fontSize: 10,
marginBottom: 5,
},
cardDetailValue: {
color: "#FFFFFF",
fontFamily: Platform.OS === "ios" ? "Courier New" : "monospace",
fontSize: 14,
fontWeight: "500",
},
formContainer: {
marginTop: 20,
},
rowInputs: {
flexDirection: "row",
justifyContent: "space-between",
},
halfInput: {
flex: 0.48,
},
payButton: {
backgroundColor: "#6366F1",
paddingVertical: 16,
borderRadius: 12,
alignItems: "center",
marginTop: 30,
marginBottom: 16,
shadowColor: "#6366F1",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 6,
elevation: 8,
},
payButtonText: {
color: "#FFFFFF",
fontSize: 16,
fontWeight: "600",
},
securityNote: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginVertical: 16,
},
securityText: {
fontSize: 13,
marginLeft: 6,
},
cardPreviewContainer: {
height: CARD_HEIGHT,
borderRadius: 16,
overflow: "hidden",
backgroundColor: "#E0E0E0", // Fallback BG
},
cardNumberPreview: {
color: "#FFFFFF",
fontSize: 22,
fontWeight: "500",
letterSpacing: 3,
fontFamily: Platform.OS === "ios" ? "Courier New" : "monospace",
textAlign: "left",
marginVertical: "auto",
},
gradientBase: {
...StyleSheet.absoluteFillObject, // Make them fill their parent Animated.View
borderRadius: 16,
},
cardContentOverlay: {
...StyleSheet.absoluteFillObject,
padding: 20,
justifyContent: "space-between",
zIndex: 1,
},
cardHeaderTop: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" },
});
const Chip = () => (
<Svg width={36} height={28} viewBox="0 0 36 28" fill="none">
<Path
d="M35.678 4.144l.01 19.358c0 2.283-1.864 4.154-4.129 4.154l-26.641.022c-2.287 0-4.151-1.87-4.151-4.144L.745 4.176c0-2.295 1.864-4.154 4.14-4.154L31.538 0a4.145 4.145 0 014.14 4.144z"
fill="#F6C859"
/>
<Path
d="M14.217 23.98a.383.383 0 01-.357-.217.41.41 0 01.173-.544c.585-.305 1.377-.87 1.843-1.838.585-1.24.433-2.588.184-2.708-.065-.033-.303.098-.455.174-.38.196-.91.468-1.518.25-.596-.207-.888-.74-1.03-1-.92-1.686-1.202-6.058-.238-8.233.325-.718.759-1.153 1.3-1.283.554-.13 1.009.098 1.367.293.292.153.444.218.541.163.38-.228.369-1.62-.162-2.718-.466-.98-1.257-1.545-1.843-1.838a.4.4 0 01-.173-.544.398.398 0 01.542-.174c.694.359 1.647 1.033 2.2 2.218.553 1.164.813 3.176-.152 3.763-.487.294-.954.055-1.322-.13-.282-.142-.553-.283-.813-.218-.357.087-.596.49-.748.816-.845 1.903-.596 6.036.206 7.515.141.25.304.522.585.62.271.098.532-.022.89-.207.357-.185.747-.38 1.17-.185.91.446.845 2.393.195 3.785-.564 1.185-1.507 1.86-2.2 2.218-.065.011-.13.022-.185.022z"
fill="#7D662D"
/>
<Path
d="M12.993 10.593H5.72a.4.4 0 01-.401-.403c0-.217.173-.402.39-.402h7.273a.4.4 0 01.4.402.385.385 0 01-.39.403zM12.993 17.9H5.72a.4.4 0 01-.401-.401.4.4 0 01.4-.403h7.274a.4.4 0 01.4.402.394.394 0 01-.4.403zM22.704 23.97a.356.356 0 01-.184-.044c-.694-.36-1.648-1.033-2.2-2.219-.662-1.392-.716-3.338.184-3.784.422-.207.823 0 1.17.174.369.184.629.304.89.206.303-.108.476-.424.584-.62.803-1.479 1.04-5.622.195-7.514-.151-.327-.39-.74-.747-.816a.409.409 0 01-.304-.49.416.416 0 01.488-.304c.542.13.975.555 1.3 1.272.976 2.175.694 6.558-.227 8.233-.141.261-.434.794-1.03 1-.607.218-1.138-.054-1.517-.25-.152-.076-.39-.195-.455-.174-.239.12-.401 1.468.195 2.708.466.98 1.257 1.545 1.842 1.838a.4.4 0 01.174.544.37.37 0 01-.358.24zM21.078 8.961a.397.397 0 01-.38-.272c-.866-2.61 1.16-4.622 1.811-4.948a.398.398 0 01.542.174.41.41 0 01-.173.544c-.293.152-2.157 1.75-1.42 3.97a.394.394 0 01-.26.51c-.033.022-.076.022-.12.022z"
fill="#7D662D"
/>
<Path
d="M31.19 10.582h-7.272a.4.4 0 01-.401-.403c0-.217.184-.402.401-.402h7.273a.4.4 0 01.4.402.408.408 0 01-.4.403zM31.19 17.88h-7.272a.4.4 0 01-.401-.403.4.4 0 01.401-.403h7.273a.4.4 0 01.4.403.394.394 0 01-.4.402z"
fill="#7D662D"
/>
</Svg>
);
const VisaIcon = () => (
<Svg width={57} height={18} viewBox="0 0 57 18" fill="none">
<Path
d="M52.94.947h-3.501c-1.095 0-1.919.305-2.385 1.381l-6.72 15.324h4.77s.79-2.056.953-2.49h5.81c.14.576.553 2.49.553 2.49h4.216L52.94.947zm-5.582 10.788c.369-.957 1.778-4.687 1.778-4.687s.39-.979.617-1.577l.304 1.436s.878 3.98 1.051 4.829h-3.75zM34.287 5.591c-.011.772.975 1.294 2.557 2.066 2.613 1.197 3.826 2.632 3.794 4.546-.022 3.47-3.121 5.71-7.88 5.71-2.037-.022-3.988-.435-5.05-.892l.639-3.741.574.26c1.485.62 2.46.882 4.27.882 1.301 0 2.7-.511 2.721-1.632.011-.74-.596-1.261-2.352-2.077-1.734-.794-4.01-2.131-3.977-4.524.021-3.24 3.186-5.525 7.663-5.525 1.755 0 3.175.37 4.064.707l-.618 3.611-.412-.185a8.642 8.642 0 00-3.403-.642c-1.767-.01-2.59.74-2.59 1.436zM27.686.958l-2.829 16.694h-4.53l.552-3.241.9-5.329L23.145.969l4.54-.01zM11.406 9.56C9.89 5.7 6.27 2.491.796 1.165L.848.828h7.035c.954.043 1.723.337 1.983 1.359l1.54 7.373z"
fill="#fff"
/>
<Path
d="M21.313.98L14.17 17.64H9.38L5.314 3.72c2.916 1.882 5.398 4.862 6.276 6.928l.476 1.73L16.511.98h4.802z"
fill="#fff"
/>
</Svg>
);
const MastercardIcon = () => (
<Svg width={46} height={28} viewBox="0 0 46 28" fill="none">
<Path
d="M13.658 27.325c7.543 0 13.658-6.114 13.658-13.657S21.2.01 13.658.01C6.115.01 0 6.125 0 13.668c0 7.543 6.115 13.657 13.658 13.657z"
fill="#fff"
/>
<Path
d="M31.878 27.315c-7.535 0-13.657-6.122-13.668-13.647C18.2 6.143 24.333.01 31.858 0c7.535 0 13.657 6.122 13.668 13.647.01 7.525-6.112 13.658-13.648 13.668zm-.01-22.762c-5.023 0-9.105 4.092-9.105 9.105 0 5.013 4.092 9.105 9.105 9.105 5.024 0 9.105-4.092 9.105-9.105 0-5.034-4.081-9.116-9.105-9.105z"
fill="#fff"
/>
</Svg>
);
const AmexIcon = () => (
<Svg width={59} height={15} viewBox="0 0 59 15" fill="none">
<Path
fillRule="evenodd"
clipRule="evenodd"
d="M6.55 0L0 14.493h7.84l.973-2.31h2.221l.972 2.31h8.63V12.73l.77 1.763h4.464l.769-1.8v1.8h17.948l2.182-2.25 2.044 2.25 9.219.02-6.57-7.226L58.032 0h-9.076l-2.125 2.21L44.852 0H25.327L23.65 3.74 21.934 0H14.11v1.704L13.24 0H6.55zm26.802 2.058h10.306l3.153 3.405 3.254-3.405h3.152l-4.79 5.227 4.79 5.167h-3.295L46.77 9.007l-3.271 3.445H33.352V2.058zm2.545 4.052v-1.9h6.431l2.806 3.036-2.93 3.053h-6.307V8.226h5.623V6.11h-5.623zM8.067 2.058h3.821l4.344 9.828V2.058h4.187l3.355 7.047 3.093-7.047h4.166v10.4h-2.535l-.02-8.15-3.696 8.15h-2.268l-3.716-8.15v8.15h-5.214l-.989-2.331H7.254l-.986 2.33H3.474L8.066 2.057zm.099 5.913l1.76-4.153 1.757 4.153H8.166z"
fill="#fff"
/>
</Svg>
);