π³ A credit & debit card component. Dependency-free, themeable, localizable. One package for React, Vue, Svelte and vanilla JS.
See the codeFramework-agnostic credit/debit card visualization for your payment forms. One package: a zero-dependency vanilla core, with framework adapters as subpaths.
npm i crd-ui
crd-ui β the vanilla core: brand detection, formatting and the card renderer.crd-ui/react β React component (<Card />).crd-ui/vue β Vue 3 component (<Card />).crd-ui/svelte β Svelte 5 component (<Card />).crd-ui/styles.css β the stylesheet (crd-ui/styles.layer.css for a cascade-layered build).Starter: a checkout form with a live card preview β open it in StackBlitz, or copy the React or Next.js version.
prefers-reduced-motion aware).tilt): the card follows the pointer with a cursor-tracked
glare (hover-only β flattened on touch devices and under prefers-reduced-motion).'form' (payment-form preview, default) and 'display' for presenting a
card the user owns β dashboards, saved cards, with click-to-reveal.variant β the default sunset tints its color bloom to the
detected brand.brand override + focused mirror Stripe Elements'
metadata without ever touching the number β see
examples/stripe.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" @brand-change="(b) => console.log(b)" />
<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 -->
Pick the card's finish with the variant prop/option:
'sunset' (default) Β· 'ember' Β· 'holo' Β· 'porcelain' Β· 'graphite' Β· 'gradient'.
sunset is a light porcelain face with a color bloom that adapts to the detected brand;
gradient is the classic dark per-brand gradient. The rest are brand-agnostic finishes β
the brand still shows through its logo and the sunset bloom.
import { Card } from 'crd-ui/react';
<Card variant="holo" number={number} focused={focused} />;
import { createCard } from 'crd-ui';
const card = createCard(el, { variant: 'holo' });
card.update({ variant: 'graphite' });
Set layout="display" to present a card the user already owns β dashboards,
saved-card lists, wallet views. Expiry and CVC move to a meta row on the front,
empty values stay masked, the empty name hides, and focusing the CVC no longer
flips the card. Start with last4 and reveal by passing the real values
(fetched securely on demand) β the component only presents; it never stores data.
import { useState } from 'react';
import { Card } from 'crd-ui/react';
function SavedCard() {
const [revealed, setRevealed] = useState(false);
const details = revealed
? { number: '5355 2400 0000 5460', expiry: '08/27', cvc: '123' }
: {};
return (
<>
<Card layout="display" brand="mastercard" last4="5460" variant="graphite" {...details} />
<button onClick={() => setRevealed((r) => !r)}>
{revealed ? 'Hide' : 'Reveal details'}
</button>
</>
);
}
import { createCard } from 'crd-ui';
const card = createCard(el, { layout: 'display', brand: 'mastercard', last4: '5460' });
// later, when the user asks to reveal:
card.update({ number: '5355 2400 0000 5460', expiry: '08/27', cvc: '123' });
Add copyable to let the user click the revealed number, expiry and CVC to
copy them (with a "Copied" bubble); an optional onCopy(field, value) fires
after each copy for your own toast or analytics:
<Card layout="display" copyable brand="mastercard" last4="5460" {...details}
onCopy={(field, value) => console.log('copied', field)} />
Override the custom properties on .crd or any ancestor β the defaults are var()
fallbacks rather than 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 CSS classes (.crd--brand-visa, β¦) you can redefine entirely.
Every knob is a CSS custom property whose default is a var() fallback rather than a
declaration on .crd, so Tailwind arbitrary-property utilities theme the card with zero
config β on the card or on any ancestor, and they win over the brand and variant themes.
className (Vue/Svelte: class) targets the card root:
{/* v4: use var(--color-*); v3: use theme(colors.*) */}
<Card className="[--crd-radius:1.25rem] [--crd-color:white]
[--crd-bg:var(--color-indigo-600)]" />
--crd-bg takes a full background, so images work here too (underscores become spaces):
<Card variant="gradient"
className="[--crd-bg:url('/textures/holo.png')_center/cover]" />
One case needs setup: utilities that override the card's own rules β text-2xl against
the number's font size, say. Tailwind emits utilities inside @layer utilities, and
unlayered CSS always beats layered CSS, so import the pre-layered build and order the
layer first:
/* app.css */
@layer crd-ui, theme, base, components, utilities;
@import "tailwindcss";
import 'crd-ui/styles.layer.css'; // instead of crd-ui/styles.css
classNames)To style the card's internal parts with utility classes, pass a classNames slot map.
Your classes are merged with the built-ins (state modifiers stay intact):
<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 marks are deliberately generic (plain wordmarks / abstract shapes) so the package ships no trademarked assets. If your product is licensed to display the official logos, pass your own SVG per brand:
createCard(el, { logos: { visa: '<svg β¦>β¦</svg>' } });
The built-ins are exported as LOGOS (a Record<Brand, string> of SVG markup) if you
need to render the same marks elsewhere β a brand picker, a saved-card list β or want to
extend rather than replace them:
import { LOGOS } from 'crd-ui';
createCard(el, { logos: { ...LOGOS, visa: myLicensedVisaSvg } });
The website ships a library of original card backgrounds β free to download and use, at
the card's ratio so they fill --crd-bg without cropping:
https://crd-ui.juanda.co/#backgrounds
They're released under CC0 1.0 (public domain): use them anywhere, commercially included, no attribution required. The crd-ui library itself stays MIT.
createCard(el, {
placeholders: { name: 'NOMBRE COMPLETO' },
locale: { validThru: 'vΓ‘lida hasta' },
});
The documentation is available as plain markdown for LLMs and coding agents:
crd-ui.juanda.co/llms.txt β concise index (llms.txt convention).crd-ui.juanda.co/llms-full.txt β full docs in one markdown file.node_modules/crd-ui/llms.txt β a compact version ships inside the package.AGENTS.md guides coding agents working on this repo.react-credit-cards hasn't shipped a
release since June 2020, and its fork
react-credit-cards-2 exposes the
same prop API β so one migration covers both.
On React 18 or 19, npm i react-credit-cards fails with ERESOLVE: it declares
react@^15 || ^16 as a peer dependency. crd-ui supports React 18 and 19.
number, name, expiry, cvc and focused keep their names and values, so for most
codebases the swap is the import and the stylesheet:
- import Cards from 'react-credit-cards';
- import 'react-credit-cards/es/styles-compiled.css';
+ import { Card } from 'crd-ui/react';
+ import 'crd-ui/styles.css';
Full migration guide β β every
prop mapped, including the two that aren't drop-in (callback and acceptedCards).
pnpm install
pnpm test # vitest
pnpm build # tsup: ESM + CJS + d.ts
pnpm dev # playground
crd-ui is free and MIT licensed. If it saved you time, you can support its upkeep:
className (Vue/Svelte: class) now lands on the card root (.crd) instead of the
container element the component mounts into β matching how other component libraries
behave, and making CSS-variable theming through it actually work. If you were using it to
position the card in a layout, move those classes to a wrapper of your own.
The stylesheet no longer declares the --crd-* knobs on .crd; it reads them with the
defaults as var() fallbacks. Overrides keep working exactly as before, and now they also
work from an ancestor and from utility classes.
TypeScript
47.8%
Astro
31.5%
CSS
18.8%
π³ A credit & debit card component. Dependency-free, themeable, localizable. One package for React, Vue, Svelte and vanilla JS.
See the codeFramework-agnostic credit/debit card visualization for your payment forms. One package: a zero-dependency vanilla core, with framework adapters as subpaths.
npm i crd-ui
crd-ui β the vanilla core: brand detection, formatting and the card renderer.crd-ui/react β React component (<Card />).crd-ui/vue β Vue 3 component (<Card />).crd-ui/svelte β Svelte 5 component (<Card />).crd-ui/styles.css β the stylesheet (crd-ui/styles.layer.css for a cascade-layered build).Starter: a checkout form with a live card preview β open it in StackBlitz, or copy the React or Next.js version.
prefers-reduced-motion aware).tilt): the card follows the pointer with a cursor-tracked
glare (hover-only β flattened on touch devices and under prefers-reduced-motion).'form' (payment-form preview, default) and 'display' for presenting a
card the user owns β dashboards, saved cards, with click-to-reveal.variant β the default sunset tints its color bloom to the
detected brand.brand override + focused mirror Stripe Elements'
metadata without ever touching the number β see
examples/stripe.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" @brand-change="(b) => console.log(b)" />
<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 -->
Pick the card's finish with the variant prop/option:
'sunset' (default) Β· 'ember' Β· 'holo' Β· 'porcelain' Β· 'graphite' Β· 'gradient'.
sunset is a light porcelain face with a color bloom that adapts to the detected brand;
gradient is the classic dark per-brand gradient. The rest are brand-agnostic finishes β
the brand still shows through its logo and the sunset bloom.
import { Card } from 'crd-ui/react';
<Card variant="holo" number={number} focused={focused} />;
import { createCard } from 'crd-ui';
const card = createCard(el, { variant: 'holo' });
card.update({ variant: 'graphite' });
Set layout="display" to present a card the user already owns β dashboards,
saved-card lists, wallet views. Expiry and CVC move to a meta row on the front,
empty values stay masked, the empty name hides, and focusing the CVC no longer
flips the card. Start with last4 and reveal by passing the real values
(fetched securely on demand) β the component only presents; it never stores data.
import { useState } from 'react';
import { Card } from 'crd-ui/react';
function SavedCard() {
const [revealed, setRevealed] = useState(false);
const details = revealed
? { number: '5355 2400 0000 5460', expiry: '08/27', cvc: '123' }
: {};
return (
<>
<Card layout="display" brand="mastercard" last4="5460" variant="graphite" {...details} />
<button onClick={() => setRevealed((r) => !r)}>
{revealed ? 'Hide' : 'Reveal details'}
</button>
</>
);
}
import { createCard } from 'crd-ui';
const card = createCard(el, { layout: 'display', brand: 'mastercard', last4: '5460' });
// later, when the user asks to reveal:
card.update({ number: '5355 2400 0000 5460', expiry: '08/27', cvc: '123' });
Add copyable to let the user click the revealed number, expiry and CVC to
copy them (with a "Copied" bubble); an optional onCopy(field, value) fires
after each copy for your own toast or analytics:
<Card layout="display" copyable brand="mastercard" last4="5460" {...details}
onCopy={(field, value) => console.log('copied', field)} />
Override the custom properties on .crd or any ancestor β the defaults are var()
fallbacks rather than 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 CSS classes (.crd--brand-visa, β¦) you can redefine entirely.
Every knob is a CSS custom property whose default is a var() fallback rather than a
declaration on .crd, so Tailwind arbitrary-property utilities theme the card with zero
config β on the card or on any ancestor, and they win over the brand and variant themes.
className (Vue/Svelte: class) targets the card root:
{/* v4: use var(--color-*); v3: use theme(colors.*) */}
<Card className="[--crd-radius:1.25rem] [--crd-color:white]
[--crd-bg:var(--color-indigo-600)]" />
--crd-bg takes a full background, so images work here too (underscores become spaces):
<Card variant="gradient"
className="[--crd-bg:url('/textures/holo.png')_center/cover]" />
One case needs setup: utilities that override the card's own rules β text-2xl against
the number's font size, say. Tailwind emits utilities inside @layer utilities, and
unlayered CSS always beats layered CSS, so import the pre-layered build and order the
layer first:
/* app.css */
@layer crd-ui, theme, base, components, utilities;
@import "tailwindcss";
import 'crd-ui/styles.layer.css'; // instead of crd-ui/styles.css
classNames)To style the card's internal parts with utility classes, pass a classNames slot map.
Your classes are merged with the built-ins (state modifiers stay intact):
<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 marks are deliberately generic (plain wordmarks / abstract shapes) so the package ships no trademarked assets. If your product is licensed to display the official logos, pass your own SVG per brand:
createCard(el, { logos: { visa: '<svg β¦>β¦</svg>' } });
The built-ins are exported as LOGOS (a Record<Brand, string> of SVG markup) if you
need to render the same marks elsewhere β a brand picker, a saved-card list β or want to
extend rather than replace them:
import { LOGOS } from 'crd-ui';
createCard(el, { logos: { ...LOGOS, visa: myLicensedVisaSvg } });
The website ships a library of original card backgrounds β free to download and use, at
the card's ratio so they fill --crd-bg without cropping:
https://crd-ui.juanda.co/#backgrounds
They're released under CC0 1.0 (public domain): use them anywhere, commercially included, no attribution required. The crd-ui library itself stays MIT.
createCard(el, {
placeholders: { name: 'NOMBRE COMPLETO' },
locale: { validThru: 'vΓ‘lida hasta' },
});
The documentation is available as plain markdown for LLMs and coding agents:
crd-ui.juanda.co/llms.txt β concise index (llms.txt convention).crd-ui.juanda.co/llms-full.txt β full docs in one markdown file.node_modules/crd-ui/llms.txt β a compact version ships inside the package.AGENTS.md guides coding agents working on this repo.react-credit-cards hasn't shipped a
release since June 2020, and its fork
react-credit-cards-2 exposes the
same prop API β so one migration covers both.
On React 18 or 19, npm i react-credit-cards fails with ERESOLVE: it declares
react@^15 || ^16 as a peer dependency. crd-ui supports React 18 and 19.
number, name, expiry, cvc and focused keep their names and values, so for most
codebases the swap is the import and the stylesheet:
- import Cards from 'react-credit-cards';
- import 'react-credit-cards/es/styles-compiled.css';
+ import { Card } from 'crd-ui/react';
+ import 'crd-ui/styles.css';
Full migration guide β β every
prop mapped, including the two that aren't drop-in (callback and acceptedCards).
pnpm install
pnpm test # vitest
pnpm build # tsup: ESM + CJS + d.ts
pnpm dev # playground
crd-ui is free and MIT licensed. If it saved you time, you can support its upkeep:
className (Vue/Svelte: class) now lands on the card root (.crd) instead of the
container element the component mounts into β matching how other component libraries
behave, and making CSS-variable theming through it actually work. If you were using it to
position the card in a layout, move those classes to a wrapper of your own.
The stylesheet no longer declares the --crd-* knobs on .crd; it reads them with the
defaults as var() fallbacks. Overrides keep working exactly as before, and now they also
work from an ancestor and from utility classes.
TypeScript
47.8%
Astro
31.5%
CSS
18.8%