# crd-ui
> Framework-agnostic credit/debit card visualization for payment forms. Zero-dependency vanilla core with React, Vue and Svelte subpaths in a single npm package.
- Website: https://crd-ui.juanda.co
- npm: https://www.npmjs.com/package/crd-ui
- GitHub: https://github.com/JuandaGarcia/crd-ui
- License: MIT
## Install
```bash
npm i crd-ui
```
Subpath exports (one package for every framework):
- `crd-ui` — vanilla core: brand detection, formatting and the card renderer.
- `crd-ui/react` — React component `` (peer: react >=18).
- `crd-ui/vue` — Vue 3 component `` (peer: vue >=3).
- `crd-ui/svelte` — Svelte 5 component `` (peer: svelte >=5).
- `crd-ui/styles.css` — required stylesheet.
All framework peers are optional: install only the one you use.
## React usage
```tsx
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 (
<>
setNumber(e.target.value)}
onFocus={() => setFocused('number')}
onBlur={() => setFocused(null)}
/>
{/* name / expiry / cvc inputs alike */}
>
);
}
```
## Vanilla usage
```js
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
```
## Vue usage
```vue
```
## Svelte usage
```svelte
(focused = 'number')}
onblur={() => (focused = null)}
/>
```
## API
Props of `` — 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>` | 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>` | Custom inline-SVG brand marks. |
| `onBrandChange` | `(brand: Brand \| null) => void` | Fires when the detected brand changes (React/Svelte callback; Vue emits @brand-change). |
`CardInstance` (vanilla):
- `update(data)` — merge new values and re-render the affected parts.
- `brand` — detected brand or null: 'visa' | 'mastercard' | 'amex' | 'discover' | 'dinersclub' | 'jcb' | 'unionpay' | 'maestro' | 'elo' | 'hipercard'.
- `element` — the root `.crd` HTMLElement.
- `destroy()` — remove the card from the DOM.
## Variants
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.
## Brands
Live detection for 10 brands, each with its own theme: Visa, Mastercard, Amex, Discover,
Diners Club, JCB, UnionPay, Maestro, Elo, Hipercard.
## Display layout (dashboards)
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. Start with `last4` and reveal by passing the real values (fetched
securely on demand) — the component only presents, it never stores data.
Add `copyable` to make the revealed number/expiry/CVC click-to-copy (with a
"Copied" bubble); an optional `onCopy(field, value)` fires after each copy.
```tsx
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 */}
>
);
}
```
```js
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' });
});
```
## Theming
Override CSS custom properties on `.crd` or any ancestor:
```css
.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 value, so images work as well as gradients
(use `variant: 'gradient'` so no variant artwork overrides it):
```css
/* --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 custom property, so Tailwind arbitrary-property utilities theme
the card with zero config — on the card or any ancestor (they inherit). Use
`var(--color-*)` (v4) or `theme(colors.*)` (v3):
```tsx
// Every knob is a CSS custom property, so Tailwind arbitrary-property
// utilities theme the card — on the card or any ancestor (they inherit).
// v4: var(--color-*) · v3: theme(colors.*)
;
```
### Styling sections with classNames
The library owns the card's markup, so add utility classes to its internal parts via
a `classNames` slot map — merged with the built-in classes, using stable slot keys
(root, chip, logo, number, name, expiry, meta, metaExpiry, metaCvc, cvc…):
```tsx
// Style the card's internal sections with a classNames slot map.
// Your classes are merged with the built-ins (state modifiers stay intact).
;
// 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:
```tsx
…' }} />
```
```js
createCard(el, { logos: { visa: '' } });
```
## Localization
Every label and placeholder on the card is configurable:
```tsx
```
```js
createCard(el, {
placeholders: { name: 'NOMBRE COMPLETO' },
locale: { validThru: 'válida hasta' },
});
```
## With Stripe (or any PCI iframe provider)
crd-ui is display-only, so it composes cleanly with providers that never expose
the card number (Stripe Elements, Adyen, etc.): map the provider's detected
brand to the `brand` override and its focus events to `focused`. The digits
stay masked on the preview.
```tsx
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(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 */}
setBrand(brandFromStripe(e.brand))}
onFocus={() => setFocused('number')}
onBlur={blur('number')}
/>
{/* focusing the CVC iframe flips the card */}
setFocused('cvc')}
onBlur={blur('cvc')}
/>
```
The digits only become visible after tokenization: Stripe's PaymentMethod
reports `card.last4` and the expiry, so the `last4` option can render the
confirmed card ('•••• •••• •••• 4242') — same pattern for saved cards.
Full runnable example: https://github.com/JuandaGarcia/crd-ui/tree/main/examples/stripe
## Behavior notes for agents
- The core owns the DOM. Framework components are thin controlled wrappers: they render
an empty container, call `createCard` on mount, forward props via `card.update()`,
and `destroy()` on unmount. Never mutate the card's inner DOM directly.
- `placeholders`, `locale` and `logos` are creation-time options; to change them,
recreate the component (e.g. with a `key`).
- Setting `focused: 'cvc'` flips the card to the back with a choreographed 3D animation
(disabled under `prefers-reduced-motion`).
- A single "magic" focus ring travels between sections when `focused` changes.
- The component is display-only: it never handles or stores real card data itself —
wire it to your own inputs.