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