A credit & debit card component for React, Vue, Svelte and vanilla JS. Dependency-free. Themeable. Localizable.
Try it
Type below, or pick a test number to try a brand — it fills the form and copies to your clipboard. Brand detection, formatting and the CVC flip are all built in.
Customize
Usage
One package for every framework. The core is vanilla; adapters are subpath imports.
import { useState } from 'react';
import { Card } from 'crd-ui/react';
import 'crd-ui/styles.css';
function PaymentForm() {
const [number, setNumber] = useState('');
const [focused, setFocused] = useState(null);
return (
<>
<Card number={number} focused={focused} />
<input
value={number}
onChange={(e) => setNumber(e.target.value)}
onFocus={() => setFocused('number')}
onBlur={() => setFocused(null)}
/>
{/* name / expiry / cvc inputs alike */}
</>
);
} import { createCard } from 'crd-ui';
import 'crd-ui/styles.css';
const card = createCard(document.querySelector('#preview'), {
number: '',
name: '',
expiry: '',
cvc: '',
});
numberInput.addEventListener('input', (e) => {
card.update({ number: e.target.value });
});
cvcInput.addEventListener('focus', () => card.update({ focused: 'cvc' })); // flips
cvcInput.addEventListener('blur', () => card.update({ focused: null }));
card.brand; // 'visa' | 'mastercard' | … | null
card.destroy(); // remove from the DOM <script setup>
import { ref } from 'vue';
import { Card } from 'crd-ui/vue';
import 'crd-ui/styles.css';
const number = ref('');
const focused = ref(null);
</script>
<template>
<Card :number="number" :focused="focused" />
<input
v-model="number"
@focus="focused = 'number'"
@blur="focused = null"
/>
<!-- name / expiry / cvc inputs alike -->
</template> <script>
import Card from 'crd-ui/svelte';
import 'crd-ui/styles.css';
let number = $state('');
let focused = $state(null);
</script>
<Card {number} {focused} />
<input
bind:value={number}
onfocus={() => (focused = 'number')}
onblur={() => (focused = null)}
/>
<!-- name / expiry / cvc inputs alike --> Brands
Live detection for 10 brands, each with its own theme. This grid is rendered by the
vanilla createCard API.
Display
Set layout="display" to present a card the user already owns —
dashboards, saved-card lists, wallet views. Expiry and CVC move to the front, empty
values stay masked, and the CVC no longer flips. Start with last4 and
reveal by passing the real values (fetched securely on demand) — the
component only presents, it never stores data.
With copyable, each revealed field is click-to-copy (try it below after
revealing). An optional onCopy(field, value) fires for your own toast.
import { useState } from 'react';
import { Card } from 'crd-ui/react';
function SavedCard() {
const [revealed, setRevealed] = useState(false);
// In a real app the reveal handler fetches the sensitive values on demand.
const details = revealed
? { number: '5355 2400 0000 5460', expiry: '08/27', cvc: '123' }
: {};
return (
<>
{/* copyable makes the revealed number/exp/cvc click-to-copy */}
<Card
layout="display"
copyable
brand="mastercard"
last4="5460"
variant="graphite"
{...details}
/>
<button onClick={() => setRevealed((r) => !r)}>
{revealed ? 'Hide' : 'Reveal details'}
</button>
</>
);
} import { createCard } from 'crd-ui';
import 'crd-ui/styles.css';
const card = createCard(el, {
layout: 'display',
copyable: true, // revealed number/exp/cvc become click-to-copy
brand: 'mastercard',
last4: '5460',
variant: 'graphite',
});
// later, when the user asks to reveal (fetch the real values first):
revealBtn.addEventListener('click', () => {
card.update({ number: '5355 2400 0000 5460', expiry: '08/27', cvc: '123' });
}); <script setup>
import { ref } from 'vue';
import { Card } from 'crd-ui/vue';
import 'crd-ui/styles.css';
const details = ref({});
const reveal = () => {
// fetch the real values on demand
details.value = { number: '5355 2400 0000 5460', expiry: '08/27', cvc: '123' };
};
</script>
<template>
<Card
layout="display"
copyable
brand="mastercard"
last4="5460"
variant="graphite"
v-bind="details"
/>
<button @click="reveal">Reveal details</button>
</template> <script>
import Card from 'crd-ui/svelte';
import 'crd-ui/styles.css';
let details = $state({});
const reveal = () => {
// fetch the real values on demand
details = { number: '5355 2400 0000 5460', expiry: '08/27', cvc: '123' };
};
</script>
<Card
layout="display"
copyable
brand="mastercard"
last4="5460"
variant="graphite"
{...details}
/>
<button onclick={reveal}>Reveal details</button> Theming
Override CSS custom properties on .crd or any ancestor — the defaults
are var() fallbacks, never declarations on the card, so an inherited
value always reaches it.
.crd {
--crd-width: 340px;
--crd-radius: 18px;
--crd-bg: linear-gradient(135deg, #111, #333);
--crd-font: 'SF Mono', monospace;
}
/* Brand themes are plain classes you can redefine entirely */
.crd--brand-visa {
--crd-bg: linear-gradient(135deg, #1a1f71, #4b6cb7);
} --crd-bg is a full CSS background, so images work as well as
gradients — bring your own artwork:
/* --crd-bg is a full CSS background: images work too */
.crd {
--crd-bg: url('/textures/holo.png') center / cover no-repeat;
} With Tailwind
Every knob is a CSS variable, so Tailwind arbitrary-property utilities theme the card
with zero config. className goes to the card root, and utilities beat the
brand and variant themes:
// Every knob is a CSS custom property whose default is a var()
// fallback, never a declaration on .crd — so utilities theme the
// card with zero config, on the card or on any ancestor.
// v4: var(--color-*) · v3: theme(colors.*)
<Card
className="[--crd-radius:1.25rem] [--crd-color:white]
[--crd-bg:var(--color-indigo-600)]
[--crd-shadow:0_10px_40px_theme(colors.indigo.500/40%)]"
/>; The image background above, as a utility:
// The image background above, in Tailwind — underscores
// become spaces. variant="gradient" keeps the variant
// artwork from covering the image.
<Card
variant="gradient"
className="[--crd-bg:url('/textures/holo.png')_center/cover]"
/>;
One case needs setup: utilities that override the card's own rules — say
text-2xl against the number's font size. Tailwind puts utilities in
@layer utilities and unlayered CSS always wins, so load the pre-layered
build and order the layer first:
/* app.css — optional: only for utilities that must override the
card's own rules (font-size, letter-spacing…). Theming through
--crd-* works without any of this. Tailwind's utilities sit in
@layer utilities, and unlayered CSS always wins, so load crd-ui
pre-wrapped in a layer that you order first. */
@layer crd-ui, theme, base, components, utilities;
@import "tailwindcss";
/* …then import 'crd-ui/styles.layer.css' instead of styles.css */ Style any section
The library owns the card's markup, so to add utility classes to its internal parts
pass a classNames slot map — merged with the built-in classes, using
stable slot keys so your styles never depend on internal class names:
// Style the card's internal sections with a classNames slot map.
// Your classes merge with the built-ins (state modifiers stay).
<Card
classNames={{
root: 'shadow-2xl ring-1 ring-white/10',
number: 'tracking-widest',
name: 'uppercase',
metaExpiry: 'tabular-nums opacity-80',
}}
/>;
// Slots: root · inner · front · back · chip · logo · number ·
// footer · name · expiry · expiryLabel · expiryValue ·
// meta · metaExpiry · metaCvc · cvc The built-in brand marks are deliberately generic so the package ships no trademarked assets — pass your own SVGs if you're licensed to use the official ones:
<Card logos={{ visa: '<svg …>…</svg>' }} /> createCard(el, { logos: { visa: '<svg …>…</svg>' } }); <Card :logos="{ visa: '<svg …>…</svg>' }" /> <Card logos={{ visa: '<svg …>…</svg>' }} /> Backgrounds
Artwork for the cards you build — 45 originals, each already at
the card's ratio so it fills --crd-bg without cropping. Click any one
to download it. They're released under
CC0,
so use them anywhere, commercially included, with no attribution required.
/* Drop the downloaded file in your project and point
--crd-bg at it. variant="gradient" keeps the variant
artwork from painting over the image. */
.crd {
--crd-bg: url('/backgrounds/opal.webp') center / cover;
} Localization
Every label and placeholder on the card is configurable.
<Card
placeholders={{ name: 'NOMBRE COMPLETO' }}
locale={{ validThru: 'válida hasta' }}
/> createCard(el, {
placeholders: { name: 'NOMBRE COMPLETO' },
locale: { validThru: 'válida hasta' },
}); <Card
:placeholders="{ name: 'NOMBRE COMPLETO' }"
:locale="{ validThru: 'válida hasta' }"
/> <Card
placeholders={{ name: 'NOMBRE COMPLETO' }}
locale={{ validThru: 'válida hasta' }}
/> Stripe
crd-ui is display-only, which makes it a natural fit for PCI-scoped providers like
Stripe Elements: the card number lives in Stripe's iframes and never reaches your
code, but Stripe reports the metadata a preview needs — the detected brand
and per-field focus. Feed those to the brand and focused
props and the preview shows the right logo, flips on CVC focus, and keeps every
digit masked.
import { CardCvcElement, CardNumberElement } from '@stripe/react-stripe-js';
import { Card, brandFromStripe, type Brand } from 'crd-ui/react';
// Stripe reports the brand without ever exposing the number (PCI iframes) —
// exactly what a display-only preview needs. brandFromStripe() translates
// Stripe's slugs (e.g. 'diners' → 'dinersclub'; 'unknown' → null).
const [brand, setBrand] = useState<Brand | null>(null);
const [focused, setFocused] = useState(null);
// Stripe iframe events arrive async (postMessage): a field's blur can land
// AFTER the next field's focus — only clear if the focus is still ours.
const blur = (field) => () => setFocused((f) => (f === field ? null : f));
{/* digits stay masked — they only exist inside Stripe's iframes */}
<Card
number=""
brand={brand}
focused={focused}
/>
<CardNumberElement
onChange={(e) => setBrand(brandFromStripe(e.brand))}
onFocus={() => setFocused('number')}
onBlur={blur('number')}
/>
{/* focusing the CVC iframe flips the card */}
<CardCvcElement
onFocus={() => setFocused('cvc')}
onBlur={blur('cvc')}
/>
The digits only become visible after tokenization: Stripe's
PaymentMethod reports card.last4 and the expiry, so the
last4 prop can render the confirmed card
(•••• •••• •••• 4242) — same pattern for displaying saved cards.
Full working example (Vite + React + Stripe test mode) in
examples/stripe.
The same events exist in vanilla @stripe/stripe-js, and the pattern
applies to any provider that reports brand/focus without exposing the number.
API
Props of <Card /> — the vanilla createCard(container, options) accepts the same fields and returns
{ update, brand, element, destroy }.
| Prop | Type | Description |
|---|---|---|
number | string | Card number, formatted and masked per brand as you type. |
name | string | Cardholder name; shows a placeholder while empty. |
expiry | string | Expiry date, normalized to MM/YY. |
cvc | string | Security code, shown on the back. |
layout | 'form' | 'display' | 'form' (default) is the payment-form preview; 'display' presents an existing card for dashboards — expiry/CVC on the front, no flip, reveal by passing the real values. |
copyable | boolean | Display layout only: make the revealed number, expiry and CVC click-to-copy with a "Copied" bubble. Pairs with onCopy(field, value). Default: false. |
classNames | Partial<Record<CardSlot, string>> | Extra classes per part of the card (utility-first / Tailwind styling of internal sections), merged with the built-in classes. Slots: root, chip, logo, number, name, expiry, meta, metaExpiry, metaCvc, cvc… |
variant | 'sunset' | 'ember' | 'holo' | 'porcelain' | 'graphite' | 'gradient' | Card finish; 'sunset' (default) tints its bloom to the brand. |
tilt | boolean | Pointer-tracked 3D hover tilt with a light glare; toggleable any time. Hover-only (flattened on touch devices). Default: false. |
brand | Brand | null | Force the displayed brand when the number never reaches you (e.g. Stripe Elements); omit for automatic detection from number. |
last4 | string | Show only the last digits ('•••• 4242') when the full number is unknown — saved cards or post-tokenization (e.g. Stripe's PaymentMethod.card.last4). |
focused | 'number' | 'name' | 'expiry' | 'cvc' | null | Highlights the section; 'cvc' flips the card. |
placeholders | { name?: string } | Placeholder text for the empty name. |
locale | { validThru?: string } | Label next to the expiry date. |
logos | Partial<Record<Brand, string>> | Custom inline-SVG brand marks. |
onBrandChange | (brand: Brand | null) => void | Fires when the detected brand changes (React/Svelte callback; Vue emits @brand-change). |
AI & agents
The docs are built for LLMs too. Everything on this page is available as plain markdown, following the llms.txt convention:
-
/llms.txt— concise index for assistants. -
/llms-full.txt— the full documentation in one markdown file (also at/index.md). -
node_modules/crd-ui/llms.txt— a compact version ships inside the npm package, so agents can read it right from your project. -
AGENTS.md— guides coding agents contributing to the repo.
Or use the Copy Page button in the top-right corner: copy the whole page as markdown, or open it directly in Claude or ChatGPT.