Skip to content
Product 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.

311 lineslucide-reactAdded 13 Sept 2026
  • inbox
  • thread
  • reply
  • support
  • shared inbox

What's included

  • components/inbox-thread-detail.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add inbox-thread-detail

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

Cracked base plate — replacement or swap?

Sam OkaforWeb formOpen 18 hours

Marte Haugen

11 Sep, 18:22

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.

Sam Okafor

Internal only12 Sep, 08:41

Third cracked plate from the 08-2026 batch this month. Flagging to quality before we ship another one.

Sam Okafor

12 Sep, 08:46

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.

Marte Haugen

12 Sep, 09:03

Perfect, thank you. Does the warranty still stand if I fit it myself?

Where does this go?

Sent to Marte and added to the thread.

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

Source

components/inbox-thread-detail.tsx
'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>
  )
}

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/inbox-thread-detail.tsx and import it where you need the section:

import { InboxThreadDetail } from '@/components/inbox-thread-detail'

Customize

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.

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
subjectstring'Cracked base plate — replacement or swap?'
assigneestring'Sam Okafor'
channelstring'Web form'
openForstring'Open 18 hours'
customerstring'Marte'
entriesThreadEntry[]DEFAULT_ENTRIES
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.

inbox-thread-detail.html
<!--
  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>
  • 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

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
Open the full page for this 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.

Communication340 lines1 dep