# 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. - `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 ( <> 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 — 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.*) ; ``` 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. ; ``` 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). ; // 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: '…' } }); ``` ## 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/.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/ On React 18 and 19, `react-credit-cards` fails to install because it declares `react@^15 || ^16` as a peer dependency: ```text npm error code ERESOLVE npm error ERESOLVE unable to resolve dependency tree npm error npm error Found: react@19.3.0 npm error Could not resolve dependency: npm error peer react@"^15.0.0 || ^16.0.0" from react-credit-cards@0.8.3 ``` `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'; ; ``` ```tsx import { Card } from 'crd-ui/react'; import 'crd-ui/styles.css'; ; ``` 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']; setRejected(!!b && !ACCEPTED.includes(b))} />; ``` ## 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.