Skip to content
Commerce block

Refund Status Tracker

Where the money is and who is holding it - four stages, each naming the responsible party, including the bank stage every where-is-my-refund contact comes from.

241 lineslucide-reactAdded 13 Sept 2026
  • refund
  • status
  • tracker
  • timeline
  • after-sale

What's included

  • components/refund-status-tracker.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add refund-status-tracker

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

Refund REF-20418

£928.00

Back to Visa ending 4417. Currently: Refund released.

  1. Request received

    Done· 3 September, 14:20

    With Northwind

    Three lines from order ORD-88410, with photographs attached.

  2. Returned goods checked in

    Done· 8 September, 09:05

    With Northwind · Rotterdam DC

    Two of three lines arrived. The lamp is still with the carrier.

  3. Refund released

    In progress· 9 September, 11:40

    With Northwind

    Sent to the card ending 4417. From this point the money is with your bank, not with us.

  4. Posted to your account

    Waiting

    With Your bank

    Most banks post within 3–5 working days. We cannot make this faster and neither can our payment provider.

Nothing has gone wrong if the last stage is still open on day three. Banks post refunds in batches, and a weekend does not count as a working day.

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

Source

components/refund-status-tracker.tsx
/**
 * <RefundStatusTracker> — where the money is, and who is holding it.
 *
 * A refund is the only part of e-commerce where the customer is waiting and
 * the company has already been paid, which is why "Refund processed" as a
 * final state is so infuriating: processed by whom, and why is the money not
 * there.
 *
 * THE BLOCK'S ONE IDEA: NAME THE PARTY THAT HAS IT NOW
 *
 * Four stages, and each one says who is responsible and how long they
 * typically take. The last stage is the important one and it belongs to the
 * *bank*, not the retailer — the merchant releases the money on day one and it
 * lands on day four, and that gap is where every "where is my refund" contact
 * comes from. Attributing it correctly is not passing the buck; it is the
 * difference between "we are ignoring you" and "your bank posts on Tuesdays".
 *
 * WHY A VERTICAL RAIL AND NOT A HORIZONTAL STEPPER
 *
 * Each stage carries a date, an actor and a sentence of explanation. That does
 * not fit under a horizontal node at any width, and the responsive fallback of
 * a horizontal stepper is always a vertical one, so it may as well be built
 * that way once. `order-tracking-timeline` in this catalog takes the same
 * shape for the same reason.
 *
 * THE FAILED STATE IS DRAWN, NOT LEFT AS AN EXERCISE. A refund can bounce —
 * a closed card is common on anything bought more than 90 days ago — and the
 * recovery is a form, not a support email. Give any stage `state: 'failed'`
 * and the recovery panel replaces the reassurance note at the foot.
 *
 * SERVER COMPONENT. Nothing here changes without a page load; a status tracker
 * that hydrates to render the same markup is paying for JavaScript twice.
 *
 * ACCESSIBILITY: an ordered list with the current stage marked by
 * `aria-current="step"`; each state carries a word, not just a colour or an
 * icon; the summary at the top repeats the outcome so it is reachable without
 * walking the list.
 */

import * as React from 'react'
import { Building2, Check, CircleAlert, Clock, Landmark, Store } from 'lucide-react'

export type StageState = 'done' | 'current' | 'waiting' | 'failed'

export interface RefundStage {
  id: string
  label: string
  /** Who holds the refund at this stage. The point of the block. */
  actor: string
  actorKind: 'you' | 'retailer' | 'bank'
  detail: string
  when?: string
  state: StageState
}

export interface RefundStatusTrackerProps {
  reference?: string
  amount?: string
  method?: string
  stages?: RefundStage[]
  className?: string
}

const DEFAULT_STAGES: RefundStage[] = [
  {
    id: 'received',
    label: 'Request received',
    actor: 'Northwind',
    actorKind: 'retailer',
    detail: 'Three lines from order ORD-88410, with photographs attached.',
    when: '3 September, 14:20',
    state: 'done',
  },
  {
    id: 'goods',
    label: 'Returned goods checked in',
    actor: 'Northwind · Rotterdam DC',
    actorKind: 'retailer',
    detail: 'Two of three lines arrived. The lamp is still with the carrier.',
    when: '8 September, 09:05',
    state: 'done',
  },
  {
    id: 'released',
    label: 'Refund released',
    actor: 'Northwind',
    actorKind: 'retailer',
    detail:
      'Sent to the card ending 4417. From this point the money is with your bank, not with us.',
    when: '9 September, 11:40',
    state: 'current',
  },
  {
    id: 'posted',
    label: 'Posted to your account',
    actor: 'Your bank',
    actorKind: 'bank',
    detail:
      'Most banks post within 3–5 working days. We cannot make this faster and neither can our payment provider.',
    state: 'waiting',
  },
]

const ACTOR_ICON: Record<RefundStage['actorKind'], typeof Store> = {
  you: Building2,
  retailer: Store,
  bank: Landmark,
}

const STATE_DOT: Record<StageState, string> = {
  done: 'bg-emerald-500 text-white',
  current: 'bg-primary text-primary-foreground',
  waiting: 'bg-muted text-muted-foreground',
  failed: 'bg-destructive text-destructive-foreground',
}

const STATE_WORD: Record<StageState, string> = {
  done: 'Done',
  current: 'In progress',
  waiting: 'Waiting',
  failed: 'Failed',
}

export function RefundStatusTracker({
  reference = 'REF-20418',
  amount = '£928.00',
  method = 'Visa ending 4417',
  stages = DEFAULT_STAGES,
  className = '',
}: RefundStatusTrackerProps) {
  const failed = stages.find((stage) => stage.state === 'failed')
  const current = stages.find((stage) => stage.state === 'current')

  return (
    <section className={`bg-background px-4 py-10 sm:px-6 ${className}`}>
      <div className="mx-auto max-w-2xl">
        <header className="rounded-2xl border border-border bg-card p-5">
          <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
            Refund {reference}
          </p>
          <p className="mt-1 text-2xl font-bold tracking-tight">{amount}</p>
          <p className="mt-1 text-sm text-muted-foreground">
            Back to {method}.{' '}
            {failed
              ? 'The payment bounced — see below.'
              : current
                ? `Currently: ${current.label}.`
                : 'Complete.'}
          </p>
        </header>

        <ol className="mt-6 space-y-1">
          {stages.map((stage, index) => {
            const ActorIcon = ACTOR_ICON[stage.actorKind]
            const last = index === stages.length - 1
            return (
              <li
                key={stage.id}
                aria-current={stage.state === 'current' ? 'step' : undefined}
                className="flex gap-3"
              >
                <div className="flex flex-col items-center">
                  <span
                    aria-hidden
                    className={`grid h-7 w-7 shrink-0 place-items-center rounded-full ${STATE_DOT[stage.state]}`}
                  >
                    {stage.state === 'done' ? (
                      <Check className="h-4 w-4" />
                    ) : stage.state === 'failed' ? (
                      <CircleAlert className="h-4 w-4" />
                    ) : (
                      <Clock className="h-3.5 w-3.5" />
                    )}
                  </span>
                  {/* border-s, not border-l: the rail follows the text. */}
                  {last ? null : (
                    <span
                      aria-hidden
                      className="my-1 w-px flex-1 border-s border-border"
                    />
                  )}
                </div>

                <div className={`min-w-0 flex-1 ${last ? 'pb-0' : 'pb-6'}`}>
                  <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
                    {stage.label ? (
                      <h3 data-stress-ignore className="text-sm font-semibold">{stage.label}</h3>
                    ) : null}
                    <span className="text-xs text-muted-foreground">
                      {STATE_WORD[stage.state]}
                    </span>
                    {stage.when ? (
                      <span className="text-xs text-muted-foreground">· {stage.when}</span>
                    ) : null}
                  </div>

                  {/* The point of the block. */}
                  <p className="mt-1 inline-flex items-center gap-1.5 rounded bg-muted px-1.5 py-0.5 text-xs font-medium">
                    <ActorIcon aria-hidden className="h-3 w-3" />
                    With {stage.actor}
                  </p>

                  <p className="mt-1.5 text-sm text-muted-foreground">{stage.detail}</p>
                </div>
              </li>
            )
          })}
        </ol>

        {failed ? (
          <div
            role="alert"
            className="mt-6 rounded-xl border border-destructive/40 bg-destructive/5 p-4"
          >
            <h3 data-stress-ignore className="text-sm font-semibold text-destructive">
              The card refused the refund
            </h3>
            <p className="mt-1 text-sm text-muted-foreground">
              This usually means the card has been cancelled or replaced. Give us
              bank details and the refund is re-sent within one working day — it
              is a form, not an email to support.
            </p>
            <button
              type="button"
              className="mt-3 rounded-lg bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            >
              Give alternative bank details
            </button>
          </div>
        ) : (
          <p className="mt-6 rounded-xl border border-dashed border-border p-4 text-xs text-muted-foreground">
            Nothing has gone wrong if the last stage is still open on day three.
            Banks post refunds in batches, and a weekend does not count as a
            working day.
          </p>
        )}
      </div>
    </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/refund-status-tracker.tsx and import it where you need the section:

import { RefundStatusTracker } from '@/components/refund-status-tracker'

Customize

3 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
referencestring'REF-20418'
amountstring'£928.00'
methodstring'Visa ending 4417'
stagesRefundStage[]DEFAULT_STAGES
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.

refund-status-tracker.html
<!--
  Refund Status Tracker — 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 has no interactive behaviour, so nothing is missing.
-->
<section class="bg-background px-4 py-10 sm:px-6 ">
  <div class="mx-auto max-w-2xl">
    <header class="rounded-2xl border border-border bg-card p-5">
      <p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Refund REF-20418</p>
      <p class="mt-1 text-2xl font-bold tracking-tight">£928.00</p>
      <p class="mt-1 text-sm text-muted-foreground">Back to Visa ending 4417. Currently: Refund released.</p>
    </header>
    <ol class="mt-6 space-y-1">
      <li class="flex gap-3">
        <div class="flex flex-col items-center">
          <span aria-hidden="true" class="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-emerald-500 text-white">
            <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-4 w-4" aria-hidden="true">
              <path d="M20 6 9 17l-5-5"></path>
            </svg>
          </span>
          <span aria-hidden="true" class="my-1 w-px flex-1 border-s border-border"></span>
        </div>
        <div class="min-w-0 flex-1 pb-6">
          <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
            <h3 data-stress-ignore="true" class="text-sm font-semibold">Request received</h3>
            <span class="text-xs text-muted-foreground">Done</span>
            <span class="text-xs text-muted-foreground">· 3 September, 14:20</span>
          </div>
          <p class="mt-1 inline-flex items-center gap-1.5 rounded bg-muted px-1.5 py-0.5 text-xs font-medium"><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-store h-3 w-3" aria-hidden="true"><path d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"></path><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path><path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4"></path><path d="M2 7h20"></path><path d="M22 7v3a2 2 0 0 1-2 2a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 12 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 8 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 4 12a2 2 0 0 1-2-2V7"></path></svg>With Northwind</p>
          <p class="mt-1.5 text-sm text-muted-foreground">Three lines from order ORD-88410, with photographs attached.</p>
        </div>
      </li>
      <li class="flex gap-3">
        <div class="flex flex-col items-center">
          <span aria-hidden="true" class="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-emerald-500 text-white">
            <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-4 w-4" aria-hidden="true">
              <path d="M20 6 9 17l-5-5"></path>
            </svg>
          </span>
          <span aria-hidden="true" class="my-1 w-px flex-1 border-s border-border"></span>
        </div>
        <div class="min-w-0 flex-1 pb-6">
          <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
            <h3 data-stress-ignore="true" class="text-sm font-semibold">Returned goods checked in</h3>
            <span class="text-xs text-muted-foreground">Done</span>
            <span class="text-xs text-muted-foreground">· 8 September, 09:05</span>
          </div>
          <p class="mt-1 inline-flex items-center gap-1.5 rounded bg-muted px-1.5 py-0.5 text-xs font-medium"><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-store h-3 w-3" aria-hidden="true"><path d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"></path><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path><path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4"></path><path d="M2 7h20"></path><path d="M22 7v3a2 2 0 0 1-2 2a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 12 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 8 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 4 12a2 2 0 0 1-2-2V7"></path></svg>With Northwind · Rotterdam DC</p>
          <p class="mt-1.5 text-sm text-muted-foreground">Two of three lines arrived. The lamp is still with the carrier.</p>
        </div>
      </li>
      <li aria-current="step" class="flex gap-3">
        <div class="flex flex-col items-center">
          <span aria-hidden="true" class="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-primary text-primary-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-clock h-3.5 w-3.5" aria-hidden="true">
              <path d="M12 6v6l4 2"></path>
              <circle cx="12" cy="12" r="10"></circle>
            </svg>
          </span>
          <span aria-hidden="true" class="my-1 w-px flex-1 border-s border-border"></span>
        </div>
        <div class="min-w-0 flex-1 pb-6">
          <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
            <h3 data-stress-ignore="true" class="text-sm font-semibold">Refund released</h3>
            <span class="text-xs text-muted-foreground">In progress</span>
            <span class="text-xs text-muted-foreground">· 9 September, 11:40</span>
          </div>
          <p class="mt-1 inline-flex items-center gap-1.5 rounded bg-muted px-1.5 py-0.5 text-xs font-medium"><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-store h-3 w-3" aria-hidden="true"><path d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"></path><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path><path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4"></path><path d="M2 7h20"></path><path d="M22 7v3a2 2 0 0 1-2 2a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 12 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 8 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 4 12a2 2 0 0 1-2-2V7"></path></svg>With Northwind</p>
          <p class="mt-1.5 text-sm text-muted-foreground">Sent to the card ending 4417. From this point the money is with your bank, not with us.</p>
        </div>
      </li>
      <li class="flex gap-3">
        <div class="flex flex-col items-center">
          <span aria-hidden="true" class="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-muted 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-clock h-3.5 w-3.5" aria-hidden="true">
              <path d="M12 6v6l4 2"></path>
              <circle cx="12" cy="12" r="10"></circle>
            </svg>
          </span>
        </div>
        <div class="min-w-0 flex-1 pb-0">
          <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
            <h3 data-stress-ignore="true" class="text-sm font-semibold">Posted to your account</h3>
            <span class="text-xs text-muted-foreground">Waiting</span>
          </div>
          <p class="mt-1 inline-flex items-center gap-1.5 rounded bg-muted px-1.5 py-0.5 text-xs font-medium"><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-landmark h-3 w-3" aria-hidden="true"><path d="M10 18v-7"></path><path d="M11.12 2.198a2 2 0 0 1 1.76.006l7.866 3.847c.476.233.31.949-.22.949H3.474c-.53 0-.695-.716-.22-.949z"></path><path d="M14 18v-7"></path><path d="M18 18v-7"></path><path d="M3 22h18"></path><path d="M6 18v-7"></path></svg>With Your bank</p>
          <p class="mt-1.5 text-sm text-muted-foreground">Most banks post within 3–5 working days. We cannot make this faster and neither can our payment provider.</p>
        </div>
      </li>
    </ol>
    <p class="mt-6 rounded-xl border border-dashed border-border p-4 text-xs text-muted-foreground">Nothing has gone wrong if the last stage is still open on day three. Banks post refunds in batches, and a weekend does not count as a working day.</p>
  </div>
</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.
  • 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

8 more blocks in After-Sale Service

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 After-Sale Service

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 After-Sale Service blocks

View category
Open the full page for this block

Warranty Coverage Panel

A verdict for this serial number today, with exclusions given equal weight to cover and an extension offered only while it can still be bought.

After-Sale Service259 lines1 dep
Open the full page for this block

Refund Request Form

Refunds are per line, not per order: a reason on each line that drives what is asked for next, and a running total itemising the restocking fee and the delivery rule.

After-Sale Service348 lines1 dep
Open the full page for this block

Refunds Overview Table

The operator queue, sorted oldest-open-first because recency is the wrong default, with blocked as a first-class status that names its blocker.

After-Sale Service277 lines1 dep
Open the full page for this block

Return or Exchange Picker

Refund, exchange or credit decided per line, with live stock on the variants and the price difference shown before the goods are posted rather than after.

After-Sale Service331 lines1 dep
Open the full page for this block

Return Label and Instructions

The screen between approved and posted: three drop-off methods with the thing that actually blocks each one, the packing rules that get returns refused, and a date rather than a duration.

After-Sale Service285 lines1 dep
Open the full page for this block

Service Repair Request

A repair is not a return - a symptom picker that filters the service routes, warranty resolved inside the form, and an estimate with its uncertainty stated.

After-Sale Service310 lines1 dep
Open the full page for this block

Review History

The reviews you wrote and the ones you owe, with editing that is honest about being marked as edited and moderation that names the rule it applied.

After-Sale Service291 lines1 dep
Open the full page for this block

Gift Card Balance Check

A balance checker built for the two ways it goes wrong - an empty card and an expired one - answering with a transaction history rather than a bare zero.

After-Sale Service254 lines1 dep