Marte Haugen
11 Sep, 18:22The second chair arrived with a cracked base plate. Everything else is fine. I would rather fit a replacement plate myself than wait for a whole chair to be swapped.
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.
components/inbox-thread-detail.tsxnpx hoverlab add inbox-thread-detail
Or over MCP, from your editor's agent — no account needed.
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.
Start a page with this section — add more, order them, and leave with the page source.
The second chair arrived with a cracked base plate. Everything else is fine. I would rather fit a replacement plate myself than wait for a whole chair to be swapped.
Third cracked plate from the 08-2026 batch this month. Flagging to quality before we ship another one.
Sorry about that — a plate is on its way and should reach you Monday. It is four M6 bolts under the seat pan; the allen key in the box fits. If it turns out to be more than that, we will swap the whole chair and collect the old one.
Perfect, thank you. Does the warranty still stand if I fit it myself?
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <InboxThreadDetail> — the reading pane, and the reply that goes with it.
*
* The other half of `inbox-thread-list`. Reading a thread and answering it are
* one task, and splitting them across a page transition is how a support
* queue gets slow — so the composer lives at the foot of the pane and is
* always there, never behind a Reply button that scrolls the message away.
*
* QUOTED HISTORY IS COLLAPSED BY DEFAULT AND SAYS HOW MUCH
*
* Email threads accumulate. The pane shows the messages and hides the quoted
* copy of the previous message inside each one behind a control that names its
* size — "3 earlier replies, quoted" — rather than a bare ellipsis. An
* ellipsis makes people expand everything to find out; a count lets them
* decide.
*
* THE INTERNAL NOTE IS A DIFFERENT COLOUR AND SAYS SO IN WORDS
*
* The single most expensive mistake in a shared inbox is sending an internal
* note to the customer. Colour alone does not prevent it — colour-blind users,
* tired users, a screenshot in a ticket. So the composer's mode is a real
* control, the note surface is visibly different, and the send button changes
* its label to name the recipient: "Send to Marte" versus "Save internal note".
*
* THE HEADER CARRIES THE OPERATIONAL FACTS, NOT JUST THE SUBJECT. Who it is
* assigned to, which channel it came from, and how long it has been open. A
* reading pane whose header is only a subject line makes the agent go back to
* the list to answer "is this mine".
*
* ACCESSIBILITY: the pane is a labelled region so it can be jumped to; each
* message is an `<article>` with a real heading; the quoted-history control is
* a disclosure carrying both `aria-expanded` and `aria-controls`, because the
* catalog's audit flags the first without the second; the composer mode is a
* radio group, not two buttons that look like tabs.
*/
import * as React from 'react'
import { ChevronDown, Clock, Lock, Reply, Send, User } from 'lucide-react'
export interface ThreadEntry {
id: string
author: string
initials: string
role: 'customer' | 'agent' | 'note'
at: string
body: string
/** Quoted earlier replies, collapsed with a count. */
quotedCount?: number
quoted?: string
}
export interface InboxThreadDetailProps {
subject?: string
assignee?: string
channel?: string
openFor?: string
customer?: string
entries?: ThreadEntry[]
className?: string
}
const DEFAULT_ENTRIES: ThreadEntry[] = [
{
id: 'e1',
author: 'Marte Haugen',
initials: 'MH',
role: 'customer',
at: '11 Sep, 18:22',
body:
'The second chair arrived with a cracked base plate. Everything else is fine. I would rather fit a replacement plate myself than wait for a whole chair to be swapped.',
},
{
id: 'e2',
author: 'Sam Okafor',
initials: 'SO',
role: 'note',
at: '12 Sep, 08:41',
body:
'Third cracked plate from the 08-2026 batch this month. Flagging to quality before we ship another one.',
},
{
id: 'e3',
author: 'Sam Okafor',
initials: 'SO',
role: 'agent',
at: '12 Sep, 08:46',
body:
'Sorry about that — a plate is on its way and should reach you Monday. It is four M6 bolts under the seat pan; the allen key in the box fits. If it turns out to be more than that, we will swap the whole chair and collect the old one.',
quotedCount: 1,
quoted:
'The second chair arrived with a cracked base plate. Everything else is fine.',
},
{
id: 'e4',
author: 'Marte Haugen',
initials: 'MH',
role: 'customer',
at: '12 Sep, 09:03',
body: 'Perfect, thank you. Does the warranty still stand if I fit it myself?',
},
]
const ROLE_STYLE: Record<ThreadEntry['role'], string> = {
customer: 'border-border bg-card',
agent: 'border-primary/30 bg-primary/5',
note: 'border-amber-500/40 bg-amber-500/5',
}
export function InboxThreadDetail({
subject = 'Cracked base plate — replacement or swap?',
assignee = 'Sam Okafor',
channel = 'Web form',
openFor = 'Open 18 hours',
customer = 'Marte',
entries = DEFAULT_ENTRIES,
className = '',
}: InboxThreadDetailProps) {
const uid = React.useId()
const [expanded, setExpanded] = React.useState<string[]>([])
const [mode, setMode] = React.useState<'reply' | 'note'>('reply')
const [draft, setDraft] = React.useState('')
function toggle(id: string) {
setExpanded((e) => (e.includes(id) ? e.filter((x) => x !== id) : [...e, id]))
}
return (
<section
aria-label="Thread"
className={`bg-background px-4 py-10 sm:px-6 ${className}`}
>
<div className="mx-auto flex min-h-[38rem] max-w-2xl flex-col overflow-hidden rounded-2xl border border-border bg-card">
{/* Operational facts, not just a subject line. */}
<header className="border-b border-border p-4">
<h2 className="text-base font-semibold">{subject}</h2>
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 font-medium">
<User aria-hidden className="h-3 w-3" />
{assignee}
</span>
<span className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 font-medium">
{channel}
</span>
<span className="inline-flex items-center gap-1">
<Clock aria-hidden className="h-3 w-3" />
{openFor}
</span>
</div>
</header>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
{entries.map((entry) => {
const open = expanded.includes(entry.id)
return (
<article
key={entry.id}
className={`rounded-xl border p-3 ${ROLE_STYLE[entry.role]}`}
>
<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"
>
{entry.initials}
</span>
{entry.author ? (
<h3 data-stress-ignore className="text-sm font-semibold">{entry.author}</h3>
) : null}
{entry.role === 'note' ? (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:text-amber-400">
<Lock aria-hidden className="h-2.5 w-2.5" />
Internal only
</span>
) : null}
<span className="ms-auto text-[11px] text-muted-foreground">
{entry.at}
</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">{entry.body}</p>
{/* A count, not an ellipsis. */}
{entry.quotedCount ? (
<div className="mt-2">
<button
type="button"
onClick={() => toggle(entry.id)}
aria-expanded={open}
aria-controls={`${uid}-quoted-${entry.id}`}
className="inline-flex items-center gap-1 rounded text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronDown
aria-hidden
className={`h-3 w-3 transition-transform ${open ? 'rotate-180' : ''}`}
/>
{entry.quotedCount} earlier{' '}
{entry.quotedCount === 1 ? 'reply' : 'replies'}, quoted
</button>
{open ? (
<blockquote
id={`${uid}-quoted-${entry.id}`}
className="mt-2 border-s-2 border-border ps-3 text-xs text-muted-foreground"
>
{entry.quoted}
</blockquote>
) : null}
</div>
) : null}
</article>
)
})}
</div>
{/* Always there. Not behind a Reply button that scrolls the thread. */}
<form
onSubmit={(e) => {
e.preventDefault()
setDraft('')
}}
className={`border-t border-border p-3 transition-colors ${
mode === 'note' ? 'bg-amber-500/5' : ''
}`}
>
<fieldset className="border-0 p-0">
<legend className="sr-only">Where does this go?</legend>
<div className="flex gap-1">
{(
[
{ id: 'reply', label: 'Reply to customer', icon: Reply },
{ id: 'note', label: 'Internal note', icon: Lock },
] as const
).map((option) => {
const Icon = option.icon
const on = mode === option.id
return (
<div key={option.id}>
<input
type="radio"
id={`${uid}-mode-${option.id}`}
name={`${uid}-mode`}
checked={on}
onChange={() => setMode(option.id)}
className="peer sr-only"
/>
<label
htmlFor={`${uid}-mode-${option.id}`}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring ${
on
? option.id === 'note'
? 'bg-amber-500/20 text-amber-800 dark:text-amber-300'
: 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon aria-hidden className="h-3.5 w-3.5 rtl:rotate-180" />
{option.label}
</label>
</div>
)
})}
</div>
</fieldset>
<label htmlFor={`${uid}-draft`} className="sr-only">
{mode === 'note' ? 'Write an internal note' : `Reply to ${customer}`}
</label>
<textarea
id={`${uid}-draft`}
rows={3}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={
mode === 'note'
? 'Only your team sees this.'
: `${customer} will receive this by email.`
}
className="mt-2 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 items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{mode === 'note'
? 'Never sent to the customer. Visible to everyone on the team.'
: `Sent to ${customer} and added to the thread.`}
</p>
{/* The label names the recipient. Colour alone is not a guard. */}
<button
type="submit"
disabled={!draft.trim()}
className={`inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-semibold 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 ${
mode === 'note'
? 'bg-amber-600 text-white'
: 'bg-primary text-primary-foreground'
}`}
>
{mode === 'note' ? (
<Lock aria-hidden className="h-4 w-4" />
) : (
<Send aria-hidden className="h-4 w-4 rtl:rotate-180" />
)}
{mode === 'note' ? 'Save internal note' : `Send to ${customer}`}
</button>
</div>
</form>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/inbox-thread-detail.tsx and import it where you need the section:
import { InboxThreadDetail } from '@/components/inbox-thread-detail'5 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.
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.
| Prop | Type | Default |
|---|---|---|
subject | string | 'Cracked base plate — replacement or swap?' |
assignee | string | 'Sam Okafor' |
channel | string | 'Web form' |
openFor | string | 'Open 18 hours' |
customer | string | 'Marte' |
entries | ThreadEntry[] | DEFAULT_ENTRIES |
className | string | '' |
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.
<!--
Inbox Reading Pane — 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 aria-label="Thread" class="bg-background px-4 py-10 sm:px-6 ">
<div class="mx-auto flex min-h-[38rem] max-w-2xl flex-col overflow-hidden rounded-2xl border border-border bg-card">
<header class="border-b border-border p-4">
<h2 class="text-base font-semibold">Cracked base plate — replacement or swap?</h2>
<div class="mt-1.5 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 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-user h-3 w-3" aria-hidden="true"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>Sam Okafor</span>
<span class="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 font-medium">Web form</span>
<span class="inline-flex items-center gap-1"><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 w-3" aria-hidden="true"><path d="M12 6v6l4 2"></path><circle cx="12" cy="12" r="10"></circle></svg>Open 18 hours</span>
</div>
</header>
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
<article class="rounded-xl border p-3 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">MH</span>
<h3 data-stress-ignore="true" class="text-sm font-semibold">Marte Haugen</h3>
<span class="ms-auto text-[11px] text-muted-foreground">11 Sep, 18:22</span>
</div>
<p class="mt-2 text-sm text-muted-foreground">The second chair arrived with a cracked base plate. Everything else is fine. I would rather fit a replacement plate myself than wait for a whole chair to be swapped.</p>
</article>
<article class="rounded-xl border p-3 border-amber-500/40 bg-amber-500/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="inline-flex items-center gap-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:text-amber-400"><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-lock h-2.5 w-2.5" aria-hidden="true"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>Internal only</span>
<span class="ms-auto text-[11px] text-muted-foreground">12 Sep, 08:41</span>
</div>
<p class="mt-2 text-sm text-muted-foreground">Third cracked plate from the 08-2026 batch this month. Flagging to quality before we ship another one.</p>
</article>
<article class="rounded-xl border p-3 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, 08:46</span>
</div>
<p class="mt-2 text-sm text-muted-foreground">Sorry about that — a plate is on its way and should reach you Monday. It is four M6 bolts under the seat pan; the allen key in the box fits. If it turns out to be more than that, we will swap the whole chair and collect the old one.</p>
<div class="mt-2">
<button type="button" aria-expanded="false" aria-controls="_R_0_-quoted-e3" class="inline-flex items-center gap-1 rounded text-[11px] font-medium text-muted-foreground transition-colors 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-chevron-down h-3 w-3 transition-transform" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>1 earlier reply, quoted</button>
</div>
</article>
<article class="rounded-xl border p-3 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">MH</span>
<h3 data-stress-ignore="true" class="text-sm font-semibold">Marte Haugen</h3>
<span class="ms-auto text-[11px] text-muted-foreground">12 Sep, 09:03</span>
</div>
<p class="mt-2 text-sm text-muted-foreground">Perfect, thank you. Does the warranty still stand if I fit it myself?</p>
</article>
</div>
<form class="border-t border-border p-3 transition-colors ">
<fieldset class="border-0 p-0">
<legend class="sr-only">Where does this go?</legend>
<div class="flex gap-1">
<div>
<input type="radio" id="_R_0_-mode-reply" class="peer sr-only" name="_R_0_-mode" checked="" />
<label for="_R_0_-mode-reply" class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring 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-reply h-3.5 w-3.5 rtl:rotate-180" aria-hidden="true"><path d="M20 18v-2a4 4 0 0 0-4-4H4"></path><path d="m9 17-5-5 5-5"></path></svg>Reply to customer</label>
</div>
<div>
<input type="radio" id="_R_0_-mode-note" class="peer sr-only" name="_R_0_-mode" />
<label for="_R_0_-mode-note" class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring text-muted-foreground hover:text-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-lock h-3.5 w-3.5 rtl:rotate-180" aria-hidden="true"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>Internal note</label>
</div>
</div>
</fieldset>
<label for="_R_0_-draft" class="sr-only">Reply to Marte</label>
<textarea id="_R_0_-draft" rows="3" placeholder="Marte will receive this by email." class="mt-2 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 items-center justify-between gap-3">
<p class="text-[11px] text-muted-foreground">Sent to Marte and added to the thread.</p>
<button type="submit" disabled="" class="inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-semibold 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 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-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 to Marte</button>
</div>
</form>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
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.
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 CommunicationCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
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.
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.
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.
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.
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.
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.
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.
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.