Skip to content
Product block

Payment Methods On File

Cards on file with the two failures that cost the subscription: expiry called out before it fails, and the default stated on the row rather than implied by ordering.

248 lineslucide-reactAdded 24 Aug 2026
  • payment
  • card
  • billing
  • stripe
  • checkout

What's included

  • components/payment-method-card.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add payment-method-card

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Payment methods

Receipts and failed-payment notices go to billing@acme.com.

  • Visa ending 4242Default

    Expires 11/2026

  • Mastercard ending 5100

    Expires 03/2029

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/payment-method-card.tsx
'use client'

/**
 * <PaymentMethodCard> — the card on file, and the two things that go wrong
 * with it.
 *
 * Both failure modes this block is built around are silent, and both cost
 * the business the subscription rather than the support ticket:
 *
 *  - An expired card. The renewal fails, the dunning email lands in
 *    promotions, and the account lapses. So expiry is not a passive field
 *    here: a card expiring within two cycles is called out in place, before
 *    it fails, where the customer is already looking at their billing page.
 *
 *  - A second card added and never made default. Every provider's API lets
 *    you have five cards and charge the wrong one. The default is stated on
 *    the row rather than implied by ordering, and promoting a card is one
 *    click from the row itself.
 *
 * The card number is masked to the last four and the brand is a word, not a
 * logo. Shipping brand SVGs means shipping trademarks, and the last four is
 * the only part a customer actually recognises — it is what their bank app
 * shows them too.
 *
 * Nothing here touches a real payment API on purpose. A card form that
 * collects a PAN into React state is a PCI scope disaster, and every
 * processor worth using hands you an iframe or an element for exactly that
 * reason. This block is the management screen around that element.
 */

import * as React from 'react'
import { CreditCard, Check, TriangleAlert, Plus, Trash2 } from 'lucide-react'

export interface PaymentMethod {
  id: string
  /** "Visa", "Mastercard" — a word, not a logo. See the note above. */
  brand: string
  last4: string
  expMonth: number
  /** Four digits. */
  expYear: number
  isDefault?: boolean
}

export interface PaymentMethodCardProps {
  methods?: PaymentMethod[]
  /** Billing e-mail, shown because a failed charge is mailed to it. */
  billingEmail?: string
  onAdd?: () => void
  onMakeDefault?: (id: string) => void
  onRemove?: (id: string) => void
  className?: string
}

const DEFAULT_METHODS: PaymentMethod[] = [
  { id: 'pm_1', brand: 'Visa', last4: '4242', expMonth: 11, expYear: 2026, isDefault: true },
  { id: 'pm_2', brand: 'Mastercard', last4: '5100', expMonth: 3, expYear: 2029 },
]

/**
 * How a card's expiry stands relative to a given month.
 *
 * Takes "now" as arguments rather than reading the clock, so the state is a
 * pure function of its inputs — a component that calls `new Date()` during
 * render produces different markup on the server and the client, which is
 * the hydration mismatch this exact kind of block usually ships with.
 */
type Expiry = 'ok' | 'soon' | 'expired'

export function expiryState(
  expMonth: number,
  expYear: number,
  nowMonth: number,
  nowYear: number,
): Expiry {
  const months = (expYear - nowYear) * 12 + (expMonth - nowMonth)
  if (months < 0) return 'expired'
  return months <= 2 ? 'soon' : 'ok'
}

function pad(n: number): string {
  return String(n).padStart(2, '0')
}

export function PaymentMethodCard({
  methods = DEFAULT_METHODS,
  billingEmail = 'billing@acme.com',
  onAdd,
  onMakeDefault,
  onRemove,
  className,
}: PaymentMethodCardProps) {
  /*
    Per-instance prefix for every id this block emits.

    The literals these replaced were a latent duplicate the moment the
    block appeared twice on one document, and `aria-labelledby` on a
    duplicated id resolves to the first match -- so the second copy was
    labelled by the first copy's heading. Client component, so `useId` is
    the right tool.
  */
  const uid = React.useId()

  /*
    "Now" is read once, after mount, and the rows render as `ok` until it
    arrives. The alternative — `new Date()` during render — makes the
    server's month and the client's month two different strings whenever a
    request straddles midnight on the 1st, and React throws the server
    markup away for the whole subtree.
  */
  const [now, setNow] = React.useState<{ month: number; year: number } | null>(null)
  React.useEffect(() => {
    const d = new Date()
    setNow({ month: d.getMonth() + 1, year: d.getFullYear() })
  }, [])

  return (
    <section
      className={`w-full rounded-2xl border border-border/60 bg-card ${className ?? ''}`}
      aria-labelledby={`${uid}-payment-heading`}
    >
      <header className="border-b border-border/60 px-6 py-4">
        <h2 id={`${uid}-payment-heading`} className="text-base font-semibold tracking-tight">
          Payment methods
        </h2>
        <p className="mt-0.5 text-sm text-muted-foreground">
          Receipts and failed-payment notices go to {billingEmail}.
        </p>
      </header>

      <ul className="divide-y divide-border/60">
        {methods.map((method) => {
          const state = now
            ? expiryState(method.expMonth, method.expYear, now.month, now.year)
            : 'ok'
          const canRemove = !method.isDefault || methods.length === 1

          return (
            <li key={method.id} className="flex flex-wrap items-center gap-x-4 gap-y-3 px-6 py-4">
              <span
                aria-hidden
                className="flex h-9 w-12 shrink-0 items-center justify-center rounded-md border border-border/60 bg-muted/50"
              >
                <CreditCard className="h-4 w-4 text-muted-foreground" />
              </span>

              <div className="min-w-0 flex-1">
                <p className="flex flex-wrap items-center gap-2 text-sm font-medium">
                  {/*
                    A bullet, not asterisks: the mask is decoration, and a
                    screen reader announcing twelve asterisks before four
                    digits is worse than "Visa ending 4242".
                  */}
                  <span aria-hidden>
                    {method.brand} •••• {method.last4}
                  </span>
                  <span className="sr-only">
                    {method.brand} ending {method.last4}
                  </span>

                  {method.isDefault ? (
                    <span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
                      <Check aria-hidden className="h-3 w-3" />
                      Default
                    </span>
                  ) : null}
                </p>

                <p
                  className={`mt-0.5 text-xs ${
                    state === 'expired'
                      ? 'text-destructive'
                      : state === 'soon'
                        ? 'text-amber-600 dark:text-amber-500'
                        : 'text-muted-foreground'
                  }`}
                >
                  {state === 'expired' ? (
                    <span className="inline-flex items-center gap-1">
                      <TriangleAlert aria-hidden className="h-3 w-3" />
                      Expired {pad(method.expMonth)}/{method.expYear} — renewals on this
                      card will fail
                    </span>
                  ) : state === 'soon' ? (
                    <span className="inline-flex items-center gap-1">
                      <TriangleAlert aria-hidden className="h-3 w-3" />
                      Expires {pad(method.expMonth)}/{method.expYear} — update it before
                      your next renewal
                    </span>
                  ) : (
                    <>
                      Expires {pad(method.expMonth)}/{method.expYear}
                    </>
                  )}
                </p>
              </div>

              <div className="flex items-center gap-2">
                {!method.isDefault ? (
                  <button
                    type="button"
                    onClick={() => onMakeDefault?.(method.id)}
                    className="rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
                  >
                    Make default
                  </button>
                ) : null}
                {/*
                  Removing the only card is allowed; removing the default
                  while another exists is not, because the result would be
                  an account with cards and no card to charge. Disabled with
                  a `title` rather than hidden — a control that vanishes
                  teaches nothing about why.
                */}
                <button
                  type="button"
                  disabled={!canRemove}
                  onClick={() => onRemove?.(method.id)}
                  title={
                    canRemove
                      ? `Remove ${method.brand} ending ${method.last4}`
                      : 'Make another card the default before removing this one'
                  }
                  aria-label={`Remove ${method.brand} ending ${method.last4}`}
                  className="rounded-lg border border-border p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
                >
                  <Trash2 aria-hidden className="h-3.5 w-3.5" />
                </button>
              </div>
            </li>
          )
        })}
      </ul>

      <footer className="border-t border-border/60 px-6 py-4">
        <button
          type="button"
          onClick={onAdd}
          className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-muted"
        >
          <Plus aria-hidden className="h-4 w-4" />
          Add payment method
        </button>
      </footer>
    </section>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/payment-method-card.tsx and import it where you need the section:

import { PaymentMethodCard } from '@/components/payment-method-card'

Customize

1 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
methodsPaymentMethod[]DEFAULT_METHODS
billingEmailBilling e-mail, shown because a failed charge is mailed to it.string'billing@acme.com'
onAdd() => void—
onMakeDefault(id: string) => void—
onRemove(id: string) => void—
classNamestring—

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

payment-method-card.html
<!--
  Payment Methods On File — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<section class="w-full rounded-2xl border border-border/60 bg-card " aria-labelledby="_R_0_-payment-heading">
  <header class="border-b border-border/60 px-6 py-4">
    <h2 id="_R_0_-payment-heading" class="text-base font-semibold tracking-tight">Payment methods</h2>
    <p class="mt-0.5 text-sm text-muted-foreground">Receipts and failed-payment notices go to billing@acme.com.</p>
  </header>
  <ul class="divide-y divide-border/60">
    <li class="flex flex-wrap items-center gap-x-4 gap-y-3 px-6 py-4">
      <span aria-hidden="true" class="flex h-9 w-12 shrink-0 items-center justify-center rounded-md border border-border/60 bg-muted/50">
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-credit-card h-4 w-4 text-muted-foreground" aria-hidden="true">
          <rect width="20" height="14" x="2" y="5" rx="2"></rect>
          <line x1="2" x2="22" y1="10" y2="10"></line>
        </svg>
      </span>
      <div class="min-w-0 flex-1">
        <p class="flex flex-wrap items-center gap-2 text-sm font-medium">
          <span aria-hidden="true">Visa •••• 4242</span>
          <span class="sr-only">Visa ending 4242</span>
          <span class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-3 w-3" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>Default</span>
        </p>
        <p class="mt-0.5 text-xs text-muted-foreground">Expires 11/2026</p>
      </div>
      <div class="flex items-center gap-2">
        <button type="button" disabled="" title="Make another card the default before removing this one" aria-label="Remove Visa ending 4242" class="rounded-lg border border-border p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2 lucide-trash-2 h-3.5 w-3.5" aria-hidden="true">
            <path d="M3 6h18"></path>
            <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
            <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
            <line x1="10" x2="10" y1="11" y2="17"></line>
            <line x1="14" x2="14" y1="11" y2="17"></line>
          </svg>
        </button>
      </div>
    </li>
    <li class="flex flex-wrap items-center gap-x-4 gap-y-3 px-6 py-4">
      <span aria-hidden="true" class="flex h-9 w-12 shrink-0 items-center justify-center rounded-md border border-border/60 bg-muted/50">
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-credit-card h-4 w-4 text-muted-foreground" aria-hidden="true">
          <rect width="20" height="14" x="2" y="5" rx="2"></rect>
          <line x1="2" x2="22" y1="10" y2="10"></line>
        </svg>
      </span>
      <div class="min-w-0 flex-1">
        <p class="flex flex-wrap items-center gap-2 text-sm font-medium">
          <span aria-hidden="true">Mastercard •••• 5100</span>
          <span class="sr-only">Mastercard ending 5100</span>
        </p>
        <p class="mt-0.5 text-xs text-muted-foreground">Expires 03/2029</p>
      </div>
      <div class="flex items-center gap-2">
        <button type="button" class="rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted">Make default</button>
        <button type="button" title="Remove Mastercard ending 5100" aria-label="Remove Mastercard ending 5100" class="rounded-lg border border-border p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2 lucide-trash-2 h-3.5 w-3.5" aria-hidden="true">
            <path d="M3 6h18"></path>
            <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
            <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
            <line x1="10" x2="10" y1="11" y2="17"></line>
            <line x1="14" x2="14" y1="11" y2="17"></line>
          </svg>
        </button>
      </div>
    </li>
  </ul>
  <footer class="border-t border-border/60 px-6 py-4">
    <button type="button" class="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-muted"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus h-4 w-4" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>Add payment method</button>
  </footer>
</section>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

7 more blocks in Billing & Usage

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Billing & Usage

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Billing & Usage blocks

View category
Open the full page for this block

Plan & Next Charge Summary

Current plan, next charge with an actual figure and date, payment method, and a prominent pending-cancellation state.

Billing & Usage151 lines1 dep
Open the full page for this block

Invoice History

Past invoices with tabular figures, worded statuses rather than bare coloured dots, and per-invoice download labels.

Billing & Usage174 lines1 dep
Open the full page for this block

Subscription Cancel Flow

Self-serve cancellation with no dark patterns: the exact date access ends, what breaks, one honest alternative, and a button that works.

Billing & Usage273 lines1 dep
Open the full page for this block

Overage Notice with Projection

Past the included allowance, with the end-of-month projection and every number the invoice will use — because a surprise bill is a product defect.

Billing & Usage230 lines1 dep
Open the full page for this block

Seat Count & Proration

Changing seats with the bill shown first: the part-period charge and the new recurring amount as two numbers, the arithmetic spelled out, and the asymmetry stated where it bites — removing a seat refunds nothing today.

Billing & Usage254 lines1 dep
Open the full page for this block

Invoice Detail

One invoice with the arithmetic shown: proration split into a credit and a charge with their own date ranges, tax as a line with its jurisdiction, and totals in a real tfoot.

Billing & Usage265 lines1 dep
Open the full page for this block

Credit Balance and Expiry

Consumable credits split into the buckets they actually live in, rendered in spend order, with a proportional warning that only appears when a meaningful balance is about to expire.

Billing & Usage222 lines1 dep