Skip to content
Product block

Support Ticket Thread

The customer side of a ticket, where a named owner and a deadline replace Status: Open - including the waiting-on-you state that stops the argument about response times.

340 lineslucide-reactAdded 13 Sept 2026
  • support
  • ticket
  • sla
  • status
  • thread

What's included

  • components/support-ticket-thread.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add support-ticket-thread

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

TKT-11842

Cracked base plate on ORD-88410

OpenSam Okafor

Sam Okafor replies within 3 h 34 min. That is a commitment, not an estimate. If it passes, the ticket escalates automatically and you do not have to ask.

  1. You

    12 Sep, 09:02

    The second chair from ORD-88410 arrived with a cracked base plate. I would rather fit a replacement plate myself than wait for a whole chair.

    base-plate.jpg

  2. Sam Okafor

    12 Sep, 09:46

    Sorry about that. A plate is going out today and should reach you Monday — four M6 bolts under the seat pan, and the allen key in the box fits. Fitting it yourself does not affect the warranty.

  3. You

    12 Sep, 10:11

    Great. Can you confirm the plate is for the tall variant, not the standard one?

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

Source

components/support-ticket-thread.tsx
'use client'

/**
 * <SupportTicketThread> — the customer's side of a ticket, with the clock.
 *
 * `support-ticket-form` in this catalog raises one. `inbox-thread-detail` is
 * how the agent works it. This is the third view and the one that decides
 * whether the customer chases: what they see after they have written in.
 *
 * THE ONE THING THAT STOPS A CHASE IS A COMMITMENT WITH A TIME ON IT
 *
 * Not "we have received your request". A named next step, an owner, and a
 * deadline the customer can hold you to — "Sam replies by 17:00 today" — with
 * the clock rendered as remaining time rather than as a timestamp they have to
 * do arithmetic on. Products that ship "Status: Open" get a follow-up email
 * within a day, every time.
 *
 * THE STATUS VOCABULARY IS FOUR WORDS AND ONE OF THEM IS ABOUT THE CUSTOMER
 *
 *   Open            with us, clock running
 *   Waiting on you  with the customer, clock paused — and it says so
 *   Scheduled       a date exists; the clock is irrelevant
 *   Resolved        closed, with the reopen window stated
 *
 * "Waiting on you" is the one almost every ticket UI omits, and it is the one
 * that prevents the argument about response times.
 *
 * REOPENING IS A BUTTON, NOT A NEW TICKET. A resolved ticket that can only be
 * continued by starting again loses the whole history and doubles the queue.
 * The window is finite and the panel says how long is left in it.
 *
 * ACCESSIBILITY: the status is a word plus an icon, never colour alone; the
 * SLA countdown is a polite live region updated on the minute rather than the
 * second, because a per-second live region is unusable; the reply box is
 * always present and labelled, so a keyboard user does not have to find a
 * Reply button first.
 */

import * as React from 'react'
import {
  CalendarClock,
  CircleCheck,
  CircleDot,
  Clock,
  Paperclip,
  Send,
  UserRound,
} from 'lucide-react'

export type TicketStatus = 'open' | 'waiting-on-you' | 'scheduled' | 'resolved'

export interface TicketPost {
  id: string
  author: string
  initials: string
  mine?: boolean
  at: string
  body: string
  attachment?: string
}

export interface SupportTicketThreadProps {
  reference?: string
  subject?: string
  status?: TicketStatus
  owner?: string
  /** Minutes until the promised reply. Ignored unless status is open. */
  dueInMinutes?: number
  scheduledFor?: string
  /** Days left to reopen. Only used when resolved. */
  reopenDays?: number
  posts?: TicketPost[]
  className?: string
}

const DEFAULT_POSTS: TicketPost[] = [
  {
    id: 'p1',
    author: 'You',
    initials: 'YU',
    mine: true,
    at: '12 Sep, 09:02',
    body:
      'The second chair from ORD-88410 arrived with a cracked base plate. I would rather fit a replacement plate myself than wait for a whole chair.',
    attachment: 'base-plate.jpg',
  },
  {
    id: 'p2',
    author: 'Sam Okafor',
    initials: 'SO',
    at: '12 Sep, 09:46',
    body:
      'Sorry about that. A plate is going out today and should reach you Monday — four M6 bolts under the seat pan, and the allen key in the box fits. Fitting it yourself does not affect the warranty.',
  },
  {
    id: 'p3',
    author: 'You',
    initials: 'YU',
    mine: true,
    at: '12 Sep, 10:11',
    body: 'Great. Can you confirm the plate is for the tall variant, not the standard one?',
  },
]

const STATUS_META: Record<
  TicketStatus,
  { label: string; className: string; icon: typeof CircleDot }
> = {
  open: {
    label: 'Open',
    className: 'bg-primary/10 text-primary',
    icon: CircleDot,
  },
  'waiting-on-you': {
    label: 'Waiting on you',
    className: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
    icon: Clock,
  },
  scheduled: {
    label: 'Scheduled',
    className: 'bg-muted text-muted-foreground',
    icon: CalendarClock,
  },
  resolved: {
    label: 'Resolved',
    className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
    icon: CircleCheck,
  },
}

function remaining(minutes: number) {
  if (minutes <= 0) return 'overdue'
  const h = Math.floor(minutes / 60)
  const m = minutes % 60
  if (h === 0) return `${m} min`
  return m === 0 ? `${h} h` : `${h} h ${m} min`
}

export function SupportTicketThread({
  reference = 'TKT-11842',
  subject = 'Cracked base plate on ORD-88410',
  status = 'open',
  owner = 'Sam Okafor',
  dueInMinutes = 214,
  scheduledFor = 'Monday 15 September, 08:00–12:00',
  reopenDays = 14,
  posts = DEFAULT_POSTS,
  className = '',
}: SupportTicketThreadProps) {
  const uid = React.useId()
  const [draft, setDraft] = React.useState('')
  const [thread, setThread] = React.useState(posts)
  const [minutes, setMinutes] = React.useState(dueInMinutes)

  // On the minute, not the second — a per-second live region is unusable.
  React.useEffect(() => {
    if (status !== 'open') return
    const tick = window.setInterval(() => setMinutes((m) => Math.max(0, m - 1)), 60_000)
    return () => window.clearInterval(tick)
  }, [status])

  const meta = STATUS_META[status]
  const StatusIcon = meta.icon

  function send(event: React.FormEvent) {
    event.preventDefault()
    if (!draft.trim()) return
    setThread((t) => [
      ...t,
      {
        id: `n${t.length + 1}`,
        author: 'You',
        initials: 'YU',
        mine: true,
        at: '12 Sep, 10:24',
        body: draft.trim(),
      },
    ])
    setDraft('')
  }

  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="font-mono text-xs text-muted-foreground">{reference}</p>
          <h2 className="mt-1 text-lg font-bold tracking-tight">{subject}</h2>

          <div className="mt-3 flex flex-wrap items-center gap-2">
            {/* A word and an icon, never colour alone. */}
            <span
              className={`inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-semibold ${meta.className}`}
            >
              <StatusIcon aria-hidden className="h-3.5 w-3.5" />
              {meta.label}
            </span>
            <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
              <UserRound aria-hidden className="h-3.5 w-3.5" />
              {owner}
            </span>
          </div>

          {/* A named next step with a time on it — the thing that stops a chase. */}
          <p
            aria-live="polite"
            className="mt-3 rounded-xl border border-border bg-muted/40 p-3 text-sm"
          >
            {status === 'open' ? (
              <>
                <span className="font-semibold">{owner} replies within {remaining(minutes)}.</span>{' '}
                <span className="text-muted-foreground">
                  That is a commitment, not an estimate. If it passes, the ticket
                  escalates automatically and you do not have to ask.
                </span>
              </>
            ) : null}
            {status === 'waiting-on-you' ? (
              <>
                <span className="font-semibold">Waiting for your answer.</span>{' '}
                <span className="text-muted-foreground">
                  The response clock is paused while it is with you — nobody is
                  ignoring this.
                </span>
              </>
            ) : null}
            {status === 'scheduled' ? (
              <>
                <span className="font-semibold">Booked for {scheduledFor}.</span>{' '}
                <span className="text-muted-foreground">
                  Nothing else is needed from you before then.
                </span>
              </>
            ) : null}
            {status === 'resolved' ? (
              <>
                <span className="font-semibold">Resolved.</span>{' '}
                <span className="text-muted-foreground">
                  You can reopen this ticket for another {reopenDays} days and
                  keep the whole history — after that it is a new one.
                </span>
              </>
            ) : null}
          </p>
        </header>

        <ol className="mt-5 space-y-3">
          {thread.map((post) => (
            <li key={post.id}>
              <article
                className={`rounded-xl border p-4 ${
                  post.mine ? 'border-border bg-card' : 'border-primary/30 bg-primary/5'
                }`}
              >
                <div className="flex items-center gap-2">
                  <span
                    aria-hidden
                    className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-muted text-[10px] font-bold text-muted-foreground"
                  >
                    {post.initials}
                  </span>
                  {post.author ? (
                    <h3 data-stress-ignore className="text-sm font-semibold">{post.author}</h3>
                  ) : null}
                  <span className="ms-auto text-[11px] text-muted-foreground">{post.at}</span>
                </div>
                <p className="mt-2 text-sm text-muted-foreground">{post.body}</p>
                {post.attachment ? (
                  <p className="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-muted px-2 py-1 text-xs">
                    <Paperclip aria-hidden className="h-3 w-3" />
                    {post.attachment}
                  </p>
                ) : null}
              </article>
            </li>
          ))}
        </ol>

        {status === 'resolved' ? (
          <div className="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-dashed border-border p-4">
            <p className="text-sm text-muted-foreground">
              Did this actually fix it?
            </p>
            <div className="flex gap-2">
              <button
                type="button"
                className="rounded-lg border border-border 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"
              >
                Yes, close it
              </button>
              {/* A button, not a new ticket. */}
              <button
                type="button"
                className="rounded-lg bg-primary px-3 py-1.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-background"
              >
                Reopen this ticket
              </button>
            </div>
          </div>
        ) : (
          <form
            onSubmit={send}
            className="mt-5 rounded-xl border border-border bg-card p-4"
          >
            {/* Always present, so a keyboard user need not find Reply first. */}
            <label htmlFor={`${uid}-reply`} className="block text-sm font-medium">
              Add to this ticket
            </label>
            <textarea
              id={`${uid}-reply`}
              rows={3}
              value={draft}
              onChange={(e) => setDraft(e.target.value)}
              placeholder="Anything else that would help."
              className="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring"
            />
            <div className="mt-2 flex flex-wrap items-center justify-between gap-3">
              <label
                htmlFor={`${uid}-file`}
                className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
              >
                <Paperclip aria-hidden className="h-3.5 w-3.5" />
                Attach a photo
              </label>
              <input id={`${uid}-file`} type="file" accept="image/*" className="sr-only" />
              <button
                type="submit"
                disabled={!draft.trim()}
                className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
              >
                <Send aria-hidden className="h-4 w-4 rtl:rotate-180" />
                Send
              </button>
            </div>
          </form>
        )}
      </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/support-ticket-thread.tsx and import it where you need the section:

import { SupportTicketThread } from '@/components/support-ticket-thread'

Customize

6 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'TKT-11842'
subjectstring'Cracked base plate on ORD-88410'
statusTicketStatus'open'
ownerstring'Sam Okafor'
dueInMinutesMinutes until the promised reply. Ignored unless status is open.number214
scheduledForstring'Monday 15 September, 08:00–12:00'
reopenDaysDays left to reopen. Only used when resolved.number14
postsTicketPost[]DEFAULT_POSTS
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.

support-ticket-thread.html
<!--
  Support Ticket Thread — 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="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="font-mono text-xs text-muted-foreground">TKT-11842</p>
      <h2 class="mt-1 text-lg font-bold tracking-tight">Cracked base plate on ORD-88410</h2>
      <div class="mt-3 flex flex-wrap items-center gap-2">
        <span class="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-semibold 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-circle-dot h-3.5 w-3.5" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="1"></circle></svg>Open</span>
        <span class="inline-flex items-center gap-1.5 text-xs 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-user-round h-3.5 w-3.5" aria-hidden="true"><circle cx="12" cy="8" r="5"></circle><path d="M20 21a8 8 0 0 0-16 0"></path></svg>Sam Okafor</span>
      </div>
      <p aria-live="polite" class="mt-3 rounded-xl border border-border bg-muted/40 p-3 text-sm">
        <span class="font-semibold">Sam Okafor replies within 3 h 34 min.</span>
        <span class="text-muted-foreground">That is a commitment, not an estimate. If it passes, the ticket escalates automatically and you do not have to ask.</span>
      </p>
    </header>
    <ol class="mt-5 space-y-3">
      <li>
        <article class="rounded-xl border p-4 border-border bg-card">
          <div class="flex items-center gap-2">
            <span aria-hidden="true" class="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-muted text-[10px] font-bold text-muted-foreground">YU</span>
            <h3 data-stress-ignore="true" class="text-sm font-semibold">You</h3>
            <span class="ms-auto text-[11px] text-muted-foreground">12 Sep, 09:02</span>
          </div>
          <p class="mt-2 text-sm text-muted-foreground">The second chair from ORD-88410 arrived with a cracked base plate. I would rather fit a replacement plate myself than wait for a whole chair.</p>
          <p class="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-muted px-2 py-1 text-xs"><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-paperclip h-3 w-3" aria-hidden="true"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg>base-plate.jpg</p>
        </article>
      </li>
      <li>
        <article class="rounded-xl border p-4 border-primary/30 bg-primary/5">
          <div class="flex items-center gap-2">
            <span aria-hidden="true" class="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-muted text-[10px] font-bold text-muted-foreground">SO</span>
            <h3 data-stress-ignore="true" class="text-sm font-semibold">Sam Okafor</h3>
            <span class="ms-auto text-[11px] text-muted-foreground">12 Sep, 09:46</span>
          </div>
          <p class="mt-2 text-sm text-muted-foreground">Sorry about that. A plate is going out today and should reach you Monday — four M6 bolts under the seat pan, and the allen key in the box fits. Fitting it yourself does not affect the warranty.</p>
        </article>
      </li>
      <li>
        <article class="rounded-xl border p-4 border-border bg-card">
          <div class="flex items-center gap-2">
            <span aria-hidden="true" class="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-muted text-[10px] font-bold text-muted-foreground">YU</span>
            <h3 data-stress-ignore="true" class="text-sm font-semibold">You</h3>
            <span class="ms-auto text-[11px] text-muted-foreground">12 Sep, 10:11</span>
          </div>
          <p class="mt-2 text-sm text-muted-foreground">Great. Can you confirm the plate is for the tall variant, not the standard one?</p>
        </article>
      </li>
    </ol>
    <form class="mt-5 rounded-xl border border-border bg-card p-4">
      <label for="_R_0_-reply" class="block text-sm font-medium">Add to this ticket</label>
      <textarea id="_R_0_-reply" rows="3" placeholder="Anything else that would help." class="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring"></textarea>
      <div class="mt-2 flex flex-wrap items-center justify-between gap-3">
        <label for="_R_0_-file" class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs 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-paperclip h-3.5 w-3.5" aria-hidden="true"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg>Attach a photo</label>
        <input id="_R_0_-file" type="file" accept="image/*" class="sr-only" />
        <button type="submit" disabled="" class="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"><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-send h-4 w-4 rtl:rotate-180" aria-hidden="true"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg>Send</button>
      </div>
    </form>
  </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 Communication

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 Communication

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 Communication blocks

View category
Open the full page for this block

Support Chat Widget

The corner launcher, with real availability instead of a stock reassurance, article deflection before the form, no self-opening, and a way to make it go away.

Communication307 lines1 dep
Open the full page for this block

Message Bubble Thread

Bubbles with the parts that get skipped: per-message delivery state, grouping by author and time, day separators, and a retry on the failed bubble itself.

Communication325 lines1 dep

Attachment Bubbles

Five kinds of thing that is not text - image, document, link, location and an upload still in flight - each answering the question its own kind raises.

Communication305 lines1 dep
Open the full page for this block

Inbox Thread List

The list half of a two-pane inbox, with unread and selected as visibly different states and bulk selection as a mode rather than a hover checkbox.

Communication333 lines1 dep
Open the full page for this block

Inbox Reading Pane

The reading pane with the composer always present, quoted history collapsed behind a count, and an internal-note mode whose send button names the recipient.

Communication311 lines1 dep
Open the full page for this block

Voice Message Player

A voice note you can navigate: a waveform that is a real seek control, a speed button, a transcript, and a played state that survives finishing.

Communication271 lines1 dep
Open the full page for this block

Video Call Layout

The call grid with the state that makes it usable - active speaker, per-tile connection quality, hide self-view, and the you-are-muted nudge.

Communication286 lines1 dep
Open the full page for this block

Call Device Settings

The green room, where every picker has a test beside it - an input meter, a test tone, a camera preview - and blocked permission is a state, not an error.

Communication315 lines1 dep