# 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 `<Card />` (peer: react >=18).
- `crd-ui/vue` — Vue 3 component `<Card />` (peer: vue >=3).
- `crd-ui/svelte` — Svelte 5 component `<Card />` (peer: svelte >=5).
- `crd-ui/styles.css` — required stylesheet.
- `crd-ui/styles.layer.css` — same stylesheet wrapped in an `@layer crd-ui` cascade layer.

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 (
    <>
      <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 */}
    </>
  );
}
```

## 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
<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>
```

## Svelte usage

```svelte
<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 -->
```

## 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). |

`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 */}
      <Card
        layout="display"
        copyable
        brand="mastercard"
        last4="5460"
        variant="graphite"
        {...details}
      />
      <button onClick={() => setRevealed((r) => !r)}>
        {revealed ? 'Hide' : 'Reveal details'}
      </button>
    </>
  );
}
```

```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 — the defaults are `var()`
fallbacks, never declarations on the card, so an inherited value always reaches it:

```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 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 beat the brand and variant themes.
`className` (Vue/Svelte: `class`) targets the card root. Use `var(--color-*)` (v4) or
`theme(colors.*)` (v3):

```tsx
// 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, as a utility:

```tsx
// 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 (e.g. `text-2xl`
against the number's font size). Tailwind emits utilities inside `@layer utilities`, and
unlayered CSS always beats layered CSS, so import the pre-layered build
(`crd-ui/styles.layer.css`) and declare the layer order first:

```css
/* 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 */
```

### 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 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:

```tsx
<Card logos={{ visa: '<svg …>…</svg>' }} />
```

```js
createCard(el, { logos: { visa: '<svg …>…</svg>' } });
```

## Backgrounds

A library of 45 original card backgrounds, downloadable from
https://crd-ui.juanda.co/#backgrounds — each a WebP already at the card's ratio (430x270), so it fills
`--crd-bg` without cropping. Files live at `https://crd-ui.juanda.co/backgrounds/<slug>.webp`.

Released under CC0 1.0 (public domain): use them anywhere, commercially included, with no
attribution required. https://creativecommons.org/publicdomain/zero/1.0/

```css
/* 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;
}
```

Download the file into your own project rather than hot-linking the site.

## Coming from react-credit-cards

`react-credit-cards` has had no release since June 2020; its fork
`react-credit-cards-2` is maintained but exposes the same prop API, so one migration
covers both. Full guide: https://crd-ui.juanda.co/migrate/react-credit-cards/

`number`, `name`, `expiry`, `cvc` and `focused` keep their names and values, so the
swap is close to a drop-in:

```tsx
import Cards from 'react-credit-cards';
import 'react-credit-cards/es/styles-compiled.css';

<Cards
  number={number}
  name={name}
  expiry={expiry}
  cvc={cvc}
  focused={focused}
/>;
```

```tsx
import { Card } from 'crd-ui/react';
import 'crd-ui/styles.css';

<Card
  number={number}
  name={name}
  expiry={expiry}
  cvc={cvc}
  focused={focused}
/>;
```

Prop mapping: `placeholders` is unchanged; `locale: { valid }` becomes
`locale: { validThru }`; `preview` + `issuer` become `layout="display"` + `brand` +
`last4`; the `$rccs-*` SCSS variables become `--crd-*` CSS custom properties, so there is
no stylesheet to recompile.

Two props have no direct equal. `callback(type, isValid)` reported brand, max length and
validity together; `onBrandChange` gives the brand, and the exported `detectBrand` /
`getBrandSpec` helpers give the rest:

```tsx
// callback(type, isValid) has no direct equal: onBrandChange
// reports the brand. Rebuild the rest from the exported helpers.
import { detectBrand, getBrandSpec } from 'crd-ui';

const onNumber = (value) => {
  const brand = detectBrand(value);
  if (!brand) return { brand: null, maxLength: 19, isValid: false };
  const { lengths, maskLength } = getBrandSpec(brand); // maskLength counts digits
  const digits = value.replace(/\D/g, '').length;
  return { brand, maxLength: maskLength, isValid: lengths.includes(digits) };
};
```

`acceptedCards` has no equal — gate on the detected brand yourself:

```tsx
// acceptedCards has no equal either — gate on the detected
// brand yourself, which also lets you show your own message.
const ACCEPTED = ['visa', 'mastercard'];

<Card number={number} onBrandChange={(b) => setRejected(!!b && !ACCEPTED.includes(b))} />;
```

## Localization

Every label and placeholder on the card is configurable:

```tsx
<Card
  placeholders={{ name: 'NOMBRE COMPLETO' }}
  locale={{ validThru: 'válida hasta' }}
/>
```

```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<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` 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.
