crd-ui

for
React

A credit & debit card component for React, Vue, Svelte and vanilla JS. Dependency-free. Themeable. Localizable.

GitHub

Try it

Type below, or pick a test number to try a brand — it fills the form and copies to your clipboard. Brand detection, formatting and the CVC flip are all built in.

Test card

Customize

Variant
Tilt

Usage

One package for every framework. The core is vanilla; adapters are subpath imports.

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" />
  <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 -->

Brands

Live detection for 10 brands, each with its own theme. This grid is rendered by the vanilla createCard API.

Display

Set layout="display" to present a card the user already owns — dashboards, saved-card lists, wallet views. Expiry and CVC move to the front, empty values stay masked, and 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.

With copyable, each revealed field is click-to-copy (try it below after revealing). An optional onCopy(field, value) fires for your own toast.

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>
    </>
  );
}
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' });
});
<script setup>
import { ref } from 'vue';
import { Card } from 'crd-ui/vue';
import 'crd-ui/styles.css';

const details = ref({});
const reveal = () => {
  // fetch the real values on demand
  details.value = { number: '5355 2400 0000 5460', expiry: '08/27', cvc: '123' };
};
</script>

<template>
  <Card
    layout="display"
    copyable
    brand="mastercard"
    last4="5460"
    variant="graphite"
    v-bind="details"
  />
  <button @click="reveal">Reveal details</button>
</template>
<script>
  import Card from 'crd-ui/svelte';
  import 'crd-ui/styles.css';

  let details = $state({});
  const reveal = () => {
    // fetch the real values on demand
    details = { number: '5355 2400 0000 5460', expiry: '08/27', cvc: '123' };
  };
</script>

<Card
  layout="display"
  copyable
  brand="mastercard"
  last4="5460"
  variant="graphite"
  {...details}
/>
<button onclick={reveal}>Reveal details</button>

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.

.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, so images work as well as gradients — bring your own artwork:

/* --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 variable, so Tailwind arbitrary-property utilities theme the card with zero config. className goes to the card root, and utilities beat the brand and variant themes:

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

// 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 — say text-2xl against the number's font size. Tailwind puts utilities in @layer utilities and unlayered CSS always wins, so load the pre-layered build and order the layer first:

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

Style any section

The library owns the card's markup, so to add utility classes to its internal parts pass a classNames slot map — merged with the built-in classes, using stable slot keys so your styles never depend on internal class names:

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

<Card logos={{ visa: '<svg …>…</svg>' }} />
createCard(el, { logos: { visa: '<svg …>…</svg>' } });
<Card :logos="{ visa: '<svg …>…</svg>' }" />
<Card logos={{ visa: '<svg …>…</svg>' }} />

Backgrounds

Artwork for the cards you build — 45 originals, each already at the card's ratio so it fills --crd-bg without cropping. Click any one to download it. They're released under CC0, so use them anywhere, commercially included, with no attribution required.

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

Localization

Every label and placeholder on the card is configurable.

<Card
  placeholders={{ name: 'NOMBRE COMPLETO' }}
  locale={{ validThru: 'válida hasta' }}
/>
createCard(el, {
  placeholders: { name: 'NOMBRE COMPLETO' },
  locale: { validThru: 'válida hasta' },
});
<Card
  :placeholders="{ name: 'NOMBRE COMPLETO' }"
  :locale="{ validThru: 'válida hasta' }"
/>
<Card
  placeholders={{ name: 'NOMBRE COMPLETO' }}
  locale={{ validThru: 'válida hasta' }}
/>

Stripe

crd-ui is display-only, which makes it a natural fit for PCI-scoped providers like Stripe Elements: the card number lives in Stripe's iframes and never reaches your code, but Stripe reports the metadata a preview needs — the detected brand and per-field focus. Feed those to the brand and focused props and the preview shows the right logo, flips on CVC focus, and keeps every digit masked.

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 prop can render the confirmed card (•••• •••• •••• 4242) — same pattern for displaying saved cards.

Full working example (Vite + React + Stripe test mode) in examples/stripe. The same events exist in vanilla @stripe/stripe-js, and the pattern applies to any provider that reports brand/focus without exposing the number.

API

Props of <Card /> — the vanilla createCard(container, options) accepts the same fields and returns { update, brand, element, destroy }.

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

AI & agents

The docs are built for LLMs too. Everything on this page is available as plain markdown, following the llms.txt convention:

  • /llms.txt — concise index for assistants.
  • /llms-full.txt — the full documentation in one markdown file (also at /index.md).
  • node_modules/crd-ui/llms.txt — a compact version ships inside the npm package, so agents can read it right from your project.
  • AGENTS.md — guides coding agents contributing to the repo.

Or use the Copy Page button in the top-right corner: copy the whole page as markdown, or open it directly in Claude or ChatGPT.