Skip to content
Commerce block

Discount Offer Modal

The offer popup with four rules applied: triggered by intent, a decline button of equal weight, a dismissal that is remembered, and the terms on its face.

214 lineslucide-reactAdded 13 Sept 2026
  • discount
  • popup
  • modal
  • promotion
  • commerce

What's included

  • components/discount-modal.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add discount-modal

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

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

Source

components/discount-modal.tsx
'use client'

/**
 * <DiscountModal> — the offer popup, built so it is not a dark pattern.
 *
 * Every retailer ships one and almost every one of them is hostile: it appears
 * two seconds after landing, over content nobody has read, with a decline
 * button that says "No thanks, I hate saving money" in 10px grey. That version
 * converts a little and costs a lot, and since 2023 the confirmshaming half of
 * it is explicitly illegal in several jurisdictions.
 *
 * So this is the same component with four rules applied, and each rule is a
 * line of code rather than a policy document.
 *
 *   1. **It waits for intent.** Triggered by exit intent or by depth, never by
 *      a timer on arrival. A visitor who has scrolled 60% has shown interest;
 *      one who has been on the page four seconds has shown nothing.
 *   2. **Decline is a real button.** Same size, same contrast, neutral words.
 *      "No thanks" is a sentence. "I don't want to save 10%" is a manipulation.
 *   3. **Dismissal is remembered.** Once declined it does not come back this
 *      session. A popup that reappears on the next page view is the single
 *      most common reason people leave a site.
 *   4. **The terms are on the face of it.** Minimum spend, exclusions and
 *      expiry beside the code, not behind an asterisk. A code that fails at
 *      checkout costs the sale it was meant to save.
 *
 * THE PREVIEW STARTS OPEN. This block's whole subject is the panel, and the
 * demo shows it rather than a page with an invisible trigger on it. In a real
 * page, mount it with `open={false}` and drive it from your intent hook.
 *
 * ACCESSIBILITY: `role="dialog"` with `aria-modal`, labelled by its heading
 * and described by its terms; the page behind is `aria-hidden` and pointer
 * inert; Escape closes and counts as a decline, because a dismissal that is
 * not recorded is a popup that returns.
 */

import * as React from 'react'
import { Check, Copy, Tag, X } from 'lucide-react'

export interface DiscountModalProps {
  code?: string
  headline?: string
  detail?: string
  terms?: string[]
  /** Starts open so the preview shows the panel. Drive it from intent. */
  open?: boolean
  className?: string
}

const DEFAULT_TERMS = [
  'Minimum spend £60 before delivery.',
  'Not valid on clearance, gift cards or service plans.',
  'One use per customer. Expires 30 September 2026.',
]

export function DiscountModal({
  code = 'DESK10',
  headline = '10% off your first order',
  detail =
    'Because you were about to leave, and because a code is cheaper for us than a retargeting ad.',
  terms = DEFAULT_TERMS,
  open: initiallyOpen = true,
  className = '',
}: DiscountModalProps) {
  const uid = React.useId()
  const [open, setOpen] = React.useState(initiallyOpen)
  const [declined, setDeclined] = React.useState(false)
  const [copied, setCopied] = React.useState(false)

  // Dismissal is remembered — see rule 3. In a real page this is where a
  // sessionStorage write goes; the state here keeps the block self-contained.
  function decline() {
    setOpen(false)
    setDeclined(true)
  }

  async function copyCode() {
    try {
      await navigator.clipboard?.writeText(code)
      setCopied(true)
      window.setTimeout(() => setCopied(false), 1800)
    } catch {
      /* Clipboard is absent over plain http and in some webviews. */
    }
  }

  return (
    <section
      className={`relative min-h-[28rem] overflow-hidden bg-muted/30 p-4 sm:p-6 ${className}`}
      onKeyDown={(event) => {
        if (event.key === 'Escape' && open) decline()
      }}
    >
      {/* Stand-in for the page the popup covers. */}
      <div
        aria-hidden={open || undefined}
        className={`mx-auto max-w-3xl space-y-3 transition-opacity ${
          open ? 'pointer-events-none opacity-40' : ''
        }`}
      >
        <div className="h-8 w-52 rounded bg-muted border border-transparent" />
        <div className="grid gap-3 sm:grid-cols-3">
          {[0, 1, 2].map((i) => (
            <div key={i} className="rounded-xl border border-border bg-card p-4">
              <div className="h-20 rounded-lg bg-muted border border-transparent" />
              <div className="mt-3 h-3 w-3/4 rounded bg-muted border border-transparent" />
              <div className="mt-2 h-3 w-1/2 rounded bg-muted border border-transparent" />
            </div>
          ))}
        </div>
        {declined ? (
          <p role="status" className="pt-2 text-sm text-muted-foreground">
            Declined. It will not appear again this session — that is the whole
            of rule three.
          </p>
        ) : (
          <button
            type="button"
            onClick={() => setOpen(true)}
            className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            Simulate exit intent
          </button>
        )}
      </div>

      {open ? (
        <div className="absolute inset-0 flex items-center justify-center bg-foreground/30 p-4 backdrop-blur-[2px]">
          <div
            role="dialog"
            aria-modal="true"
            aria-labelledby={`${uid}-title`}
            aria-describedby={`${uid}-terms`}
            className="w-full max-w-md rounded-2xl border border-border bg-card text-card-foreground shadow-lg"
          >
            <div className="flex items-start justify-between gap-3 p-5 pb-0">
              <span
                aria-hidden
                className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-primary/10 text-primary"
              >
                <Tag className="h-5 w-5" />
              </span>
              <button
                type="button"
                onClick={decline}
                aria-label="Close and do not show again"
                className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                <X aria-hidden className="h-4 w-4" />
              </button>
            </div>

            <div className="p-5 pt-3">
              <h2 id={`${uid}-title`} className="text-lg font-bold tracking-tight">
                {headline}
              </h2>
              <p className="mt-1.5 text-sm text-muted-foreground">{detail}</p>

              <div className="mt-4 flex items-center gap-2 rounded-xl border border-dashed border-primary/50 bg-primary/5 p-3">
                <code className="flex-1 font-mono text-lg font-bold tracking-widest text-primary">
                  {code}
                </code>
                <button
                  type="button"
                  onClick={copyCode}
                  className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-2.5 py-1.5 text-xs font-semibold transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  {copied ? (
                    <Check aria-hidden className="h-3.5 w-3.5 text-emerald-600" />
                  ) : (
                    <Copy aria-hidden className="h-3.5 w-3.5" />
                  )}
                  {copied ? 'Copied' : 'Copy code'}
                </button>
              </div>
              <p aria-live="polite" className="sr-only">
                {copied ? `Discount code ${code} copied to the clipboard` : ''}
              </p>

              {/* On the face of it, not behind an asterisk. */}
              <ul id={`${uid}-terms`} className="mt-3 space-y-1 text-xs text-muted-foreground">
                {terms.map((term) => (
                  <li key={term} className="flex gap-1.5">
                    <span aria-hidden className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-muted-foreground border border-transparent" />
                    {term}
                  </li>
                ))}
              </ul>

              {/* Equal weight, neutral words. No confirmshaming. */}
              <div className="mt-5 grid grid-cols-2 gap-2">
                <button
                  type="button"
                  onClick={decline}
                  className="rounded-lg border border-border px-4 py-2.5 text-sm font-semibold transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  No thanks
                </button>
                <button
                  type="button"
                  onClick={() => setOpen(false)}
                  className="rounded-lg bg-primary px-4 py-2.5 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-card"
                >
                  Use the code
                </button>
              </div>
            </div>
          </div>
        </div>
      ) : null}
    </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/discount-modal.tsx and import it where you need the section:

import { DiscountModal } from '@/components/discount-modal'

Customize

2 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
codestring'DESK10'
headlinestring'10% off your first order'
detailstring—
termsstring[]DEFAULT_TERMS
openStarts open so the preview shows the panel. Drive it from intent.boolean—
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.

discount-modal.html
<!--
  Discount Offer Modal — 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="relative min-h-[28rem] overflow-hidden bg-muted/30 p-4 sm:p-6 ">
  <div aria-hidden="true" class="mx-auto max-w-3xl space-y-3 transition-opacity pointer-events-none opacity-40">
    <div class="h-8 w-52 rounded bg-muted border border-transparent"></div>
    <div class="grid gap-3 sm:grid-cols-3">
      <div class="rounded-xl border border-border bg-card p-4">
        <div class="h-20 rounded-lg bg-muted border border-transparent"></div>
        <div class="mt-3 h-3 w-3/4 rounded bg-muted border border-transparent"></div>
        <div class="mt-2 h-3 w-1/2 rounded bg-muted border border-transparent"></div>
      </div>
      <div class="rounded-xl border border-border bg-card p-4">
        <div class="h-20 rounded-lg bg-muted border border-transparent"></div>
        <div class="mt-3 h-3 w-3/4 rounded bg-muted border border-transparent"></div>
        <div class="mt-2 h-3 w-1/2 rounded bg-muted border border-transparent"></div>
      </div>
      <div class="rounded-xl border border-border bg-card p-4">
        <div class="h-20 rounded-lg bg-muted border border-transparent"></div>
        <div class="mt-3 h-3 w-3/4 rounded bg-muted border border-transparent"></div>
        <div class="mt-2 h-3 w-1/2 rounded bg-muted border border-transparent"></div>
      </div>
    </div>
    <button type="button" class="rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">Simulate exit intent</button>
  </div>
  <div class="absolute inset-0 flex items-center justify-center bg-foreground/30 p-4 backdrop-blur-[2px]">
    <div role="dialog" aria-modal="true" aria-labelledby="_R_0_-title" aria-describedby="_R_0_-terms" class="w-full max-w-md rounded-2xl border border-border bg-card text-card-foreground shadow-lg">
      <div class="flex items-start justify-between gap-3 p-5 pb-0">
        <span aria-hidden="true" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-primary/10 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-tag h-5 w-5" aria-hidden="true">
            <path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"></path>
            <circle cx="7.5" cy="7.5" r=".5" fill="currentColor"></circle>
          </svg>
        </span>
        <button type="button" aria-label="Close and do not show again" class="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
          <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-x h-4 w-4" aria-hidden="true">
            <path d="M18 6 6 18"></path>
            <path d="m6 6 12 12"></path>
          </svg>
        </button>
      </div>
      <div class="p-5 pt-3">
        <h2 id="_R_0_-title" class="text-lg font-bold tracking-tight">10% off your first order</h2>
        <p class="mt-1.5 text-sm text-muted-foreground">Because you were about to leave, and because a code is cheaper for us than a retargeting ad.</p>
        <div class="mt-4 flex items-center gap-2 rounded-xl border border-dashed border-primary/50 bg-primary/5 p-3">
          <code class="flex-1 font-mono text-lg font-bold tracking-widest text-primary">DESK10</code>
          <button type="button" class="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-2.5 py-1.5 text-xs font-semibold transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><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-copy h-3.5 w-3.5" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>Copy code</button>
        </div>
        <p aria-live="polite" class="sr-only"></p>
        <ul id="_R_0_-terms" class="mt-3 space-y-1 text-xs text-muted-foreground">
          <li class="flex gap-1.5"><span aria-hidden="true" class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-muted-foreground border border-transparent"></span>Minimum spend £60 before delivery.</li>
          <li class="flex gap-1.5"><span aria-hidden="true" class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-muted-foreground border border-transparent"></span>Not valid on clearance, gift cards or service plans.</li>
          <li class="flex gap-1.5"><span aria-hidden="true" class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-muted-foreground border border-transparent"></span>One use per customer. Expires 30 September 2026.</li>
        </ul>
        <div class="mt-5 grid grid-cols-2 gap-2">
          <button type="button" class="rounded-lg border border-border px-4 py-2.5 text-sm font-semibold transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">No thanks</button>
          <button type="button" class="rounded-lg bg-primary px-4 py-2.5 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-card">Use the code</button>
        </div>
      </div>
    </div>
  </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.
  • 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

8 more blocks in Cart & Checkout

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 Cart & Checkout

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 Cart & Checkout blocks

View category
Open the full page for this block

Slide-Over Cart Drawer

A basket panel with the dialog mechanics usually missing: focus trap, focus return, Escape to close and a background scroll lock.

Cart & Checkout256 lines1 dep
Open the full page for this block

Editable Cart Lines

Quantity steppers and remove-with-undo, with every total derived rather than stored so the basket can never disagree with itself.

Cart & Checkout199 lines1 dep
Open the full page for this block

Checkout Address & Payment

The full autocomplete token set that lets a browser fill eight fields in one tap, plus address labels that follow the selected country.

Cart & Checkout259 lines1 dep
Open the full page for this block

Order Summary With Promo

Subtotal, discount, delivery and tax all derived from the lines, with the free-delivery nudge driven by the same threshold as the charge.

Cart & Checkout228 lines1 dep
Open the full page for this block

Express Payment Row

The wallet buttons above the form, and what they do to it: the address a wallet returns is shown and stays editable, and the row disappears entirely when no wallet is actually available.

Cart & Checkout258 lines1 dep
Open the full page for this block

Gift Options Form

The gifting step, placed in the cart where it is a choice rather than at checkout where it is friction.

Cart & Checkout177 linesNo deps
Open the full page for this block

Gift Card Purchase

The only checkout where the buyer is not the recipient - live preview, a send date with a stated timezone, and the confirm-the-address field that prevents the expensive typo.

Cart & Checkout361 lines1 dep
Open the full page for this block

Guest Checkout

Guest as the default and the whole form, with a real autocomplete token on every field and the account offer moved to the end, where it costs one checkbox.

Cart & Checkout396 lines1 dep