Brings a typed-but-never-sent message back as a clear "Not sent" bubble, so people don't think they replied.
StopsThought I replied but never sent it
"use client"
import * as React from "react"
import { readUnsent, saveUnsent, UnsentMarker, useUnsentDraft, type UnsentDraft } from "@/components/ui/unsent-marker"
type Chat = "priya" | "arjun"
const CHATS: Record<Chat, { name: string; last: string }> = {
priya: { name: "Priya", last: "Can you confirm the ₹500 deposit by tonight?" },
arjun: { name: "Arjun", last: "Sent you the venue photos." },
}
const keyOf = (chat: Chat) => `cui-demo-unsent:${chat}`
function Thread({ chat }: { chat: Chat }) {
const [value, setValue] = React.useState("")
const [sent, setSent] = React.useState<string[]>([])
const { draft, clear } = useUnsentDraft(keyOf(chat), value)
const send = (text: string) => {
setSent((s) => [...s, text])
setValue("")
clear()
}
return (
<div className="grid grid-cols-[minmax(0,1fr)] gap-3">
<div className="grid min-h-40 grid-cols-[minmax(0,1fr)] content-end gap-2 rounded-lg border bg-background p-3">
<p className="max-w-[85%] rounded-2xl rounded-bl-md bg-muted px-3 py-2 text-sm">{CHATS[chat].last}</p>
{sent.map((text, i) => (
<p key={i} className="ml-auto max-w-[85%] rounded-2xl rounded-br-md bg-primary px-3 py-2 text-sm text-primary-foreground">
{text}
</p>
))}
{draft && (
<UnsentMarker
text={draft.text}
at={draft.at}
recipient={CHATS[chat].name}
onSend={async (text) => {
await new Promise((resolve) => setTimeout(resolve, 400))
send(text)
}}
onEdit={(text) => {
setValue(text)
clear()
}}
onDiscard={clear}
/>
)}
</div>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault()
if (value.trim()) send(value.trim())
}}
>
<input
aria-label={`Message ${CHATS[chat].name}`}
placeholder={`Message ${CHATS[chat].name}`}
value={value}
onChange={(event) => setValue(event.target.value)}
className="h-9 min-w-0 flex-1 rounded-md border bg-transparent px-3 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30"
/>
<button type="submit" className="h-9 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
Send
</button>
</form>
</div>
)
}
export function Demo() {
const [chat, setChat] = React.useState<Chat>()
const [drafts, setDrafts] = React.useState<Partial<Record<Chat, UnsentDraft | null>>>({})
// Seed one draft "left two hours ago" before the thread mounts, so the demo opens on the catch.
React.useEffect(() => {
saveUnsent(keyOf("priya"), "Yes, confirmed! Paying the deposit tonight.", Date.now() - 2 * 3_600_000)
localStorage.removeItem(keyOf("arjun"))
setChat("priya")
}, [])
const open = (next: Chat) => {
setChat(next)
setDrafts({ priya: readUnsent(keyOf("priya")), arjun: readUnsent(keyOf("arjun")) })
}
return (
<div className="grid w-full max-w-sm min-w-0 grid-cols-[minmax(0,1fr)] gap-3">
<div role="tablist" aria-label="Chats" className="grid grid-cols-[repeat(2,minmax(0,1fr))] gap-2">
{(Object.keys(CHATS) as Chat[]).map((id) => (
<button
key={id}
role="tab"
type="button"
aria-selected={chat === id}
onClick={() => open(id)}
className="grid min-w-0 gap-0.5 rounded-lg border px-3 py-2 text-left outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-selected:border-primary/50 aria-selected:bg-accent"
>
<span className="text-sm font-medium">{CHATS[id].name}</span>
{chat !== id && drafts[id] ? (
<UnsentMarker variant="row" text={drafts[id].text} />
) : (
<span className="truncate text-xs text-muted-foreground">{CHATS[id].last}</span>
)}
</button>
))}
</div>
{chat && <Thread key={chat} chat={chat} />}
</div>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/uiunsent-marker.tsx244 lines"use client" // Bollard UI — unsent-marker · MIT licence · https://bollard-ui.vercel.app/components/unsent-marker import * as React from "react" import { CircleAlert, LoaderCircle, PenLine, SendHorizontal } from "lucide-react" import { cn } from "@/lib/utils" /** Text that was typed but never sent, with when it was last typed. */ export interface UnsentDraft { text: string at: number } /** Reads a left-behind draft, or null. */ export function readUnsent(key: string): UnsentDraft | null { try { const draft = JSON.parse(localStorage.getItem(key) ?? "null") as UnsentDraft | null return draft && typeof draft.text === "string" && draft.text.trim() ? draft : null } catch { return null } } export function saveUnsent(key: string, text: string, at = Date.now()) { try { localStorage.setItem(key, JSON.stringify({ text, at } satisfies UnsentDraft)) } catch {} } export function clearUnsent(key: string) { try { localStorage.removeItem(key) } catch {} } /** * Keeps a composer's unsent text under `key` and, on return, hands back what * was left there so it can be marked instead of silently refilled. Clear the * composer (or call `clear`) once the message is really sent. Reset `value` * whenever `key` changes. */ export function useUnsentDraft(key: string, value: string) { const [draft, setDraft] = React.useState<UnsentDraft | null>(null) const typed = React.useRef(false) React.useEffect(() => { typed.current = false setDraft(readUnsent(key)) }, [key]) // An empty composer only clears storage after typing here, so a draft found on return survives until it's handled. React.useEffect(() => { if (value.trim()) { typed.current = true saveUnsent(key, value) } else if (typed.current) clearUnsent(key) }, [key, value]) const clear = React.useCallback(() => { setDraft(null) clearUnsent(key) }, [key]) return { draft, clear } } /** "a moment ago", "12 min ago", "2 h ago", "3 days ago". */ export function typedAgo(at: number, now: number) { const minutes = Math.round((now - at) / 60_000) if (minutes < 1) return "a moment ago" if (minutes < 60) return `${minutes} min ago` if (minutes < 1440) return `${Math.round(minutes / 60)} h ago` const days = Math.round(minutes / 1440) return `${days} ${days === 1 ? "day" : "days"} ago` } const fill = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match) const button = "inline-flex h-8 items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-60" const KEYFRAMES = `@keyframes cui-unsent-marker-in{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}` export interface UnsentMarkerProps extends Omit<React.ComponentProps<"div">, "children"> { /** The unsent text. */ text: string /** When it was last typed (ms). */ at?: number /** `bubble` sits in the thread where the reply would be; `row` is a one-line preview for conversation lists. */ variant?: "bubble" | "row" /** Who it was meant for, e.g. "Priya". */ recipient?: string /** Sends it now. Return a promise to show a pending state; a rejection keeps the marker with an error. */ onSend?: (text: string) => void | Promise<void> /** Moves it back into the composer. */ onEdit?: (text: string) => void /** Throws it away (asks once). */ onDiscard?: () => void /** Label on the unsent bubble. */ notSentLabel?: string /** `{when}` is filled in. */ typedText?: string /** `{recipient}` is filled in. */ recipientText?: string /** Used when there's no `recipient`. */ privateText?: string /** Label of the Send button. */ sendLabel?: string /** Label of the Edit button. */ editLabel?: string /** Label of the Discard button. */ discardLabel?: string /** Label of the second Discard tap. */ confirmDiscardLabel?: string /** Shown when sending fails. */ failedText?: string } /** * Stops people believing they replied when the text never left the box. A * left-behind draft returns as a dashed bubble in the thread, right where the * sent reply would be, so the one place people check for "did I answer?" * tells the truth: "Not sent · Priya hasn't received it". Send, Edit and a * two-tap Discard sit under it; a failed send says so. `row` puts the same * "Not sent:" preview in conversation lists. */ export function UnsentMarker({ text, at, variant = "bubble", recipient, onSend, onEdit, onDiscard, notSentLabel = "Not sent", typedText = "Typed {when}.", recipientText = "{recipient} hasn't received it.", privateText = "Only you can see it.", sendLabel = "Send", editLabel = "Edit", discardLabel = "Discard", confirmDiscardLabel = "Discard draft?", failedText = "Couldn't send. Check your connection and try again.", className, ...props }: UnsentMarkerProps) { const [now, setNow] = React.useState<number>() const [phase, setPhase] = React.useState<"idle" | "sending" | "failed">("idle") const [confirm, setConfirm] = React.useState(false) React.useEffect(() => { setNow(Date.now()) const id = setInterval(() => setNow(Date.now()), 60_000) return () => clearInterval(id) }, []) React.useEffect(() => { if (!confirm) return const timer = setTimeout(() => setConfirm(false), 3000) return () => clearTimeout(timer) }, [confirm]) if (variant === "row") { return ( <div data-slot="unsent-marker" data-variant="row" className={cn("flex min-w-0 items-center gap-1 text-xs", className)} {...props}> <PenLine aria-hidden className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" /> <span className="shrink-0 font-medium text-amber-800 dark:text-amber-300">{notSentLabel}:</span> <span className="min-w-0 truncate text-muted-foreground">{text}</span> </div> ) } const when = at === undefined || now === undefined ? "" : typedAgo(at, now) const hint = [when && fill(typedText, { when }), recipient ? fill(recipientText, { recipient }) : privateText].filter(Boolean).join(" ") const send = async () => { if (!onSend || phase === "sending") return setPhase("sending") try { await onSend(text) setPhase("idle") } catch { setPhase("failed") } } return ( <div data-slot="unsent-marker" data-variant="bubble" data-state={phase} role="group" aria-label={`${notSentLabel}: ${text}`} className={cn("ml-auto grid max-w-[85%] justify-items-end gap-1.5 motion-safe:animate-[cui-unsent-marker-in_250ms_ease-out]", className)} {...props} > <style>{KEYFRAMES}</style> <p className={cn( "rounded-2xl rounded-br-md border-2 border-dashed px-3 py-2 text-sm leading-relaxed break-words whitespace-pre-wrap transition-colors duration-200", phase === "sending" ? "border-primary/40 text-foreground" : "border-amber-500/60 bg-amber-500/5 text-foreground/80", )} > {text} </p> <p className="flex flex-wrap items-center justify-end gap-x-2 gap-y-1 text-right text-xs"> <span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-900 dark:bg-amber-500/15 dark:text-amber-200"> <CircleAlert aria-hidden className="size-3.5" /> {notSentLabel} </span> <span className="text-muted-foreground">{hint}</span> </p> {phase === "failed" && ( <p role="alert" className="text-right text-xs text-destructive"> {failedText} </p> )} <div className="flex flex-wrap justify-end gap-2"> {onDiscard && ( <button type="button" onClick={() => (confirm ? onDiscard() : setConfirm(true))} className={cn(button, "px-2", confirm ? "text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-accent hover:text-foreground")} > {confirm ? confirmDiscardLabel : discardLabel} </button> )} {onEdit && ( <button type="button" onClick={() => onEdit(text)} className={cn(button, "border bg-background shadow-xs hover:bg-accent dark:bg-input/30")}> {editLabel} </button> )} {onSend && ( <button type="button" onClick={send} disabled={phase === "sending"} className={cn(button, "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90")}> {phase === "sending" ? <LoaderCircle aria-hidden className="size-4 animate-spin" /> : <SendHorizontal aria-hidden className="size-4" />} {sendLabel} </button> )} </div> </div> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { readUnsent, saveUnsent, UnsentMarker, useUnsentDraft, type UnsentDraft } from "@/components/ui/unsent-marker"
<UnsentMarker
text={draft.text}
at={draft.at}
recipient={CHATS[chat].name}
onSend={async (text) => {
await new Promise((resolve) => setTimeout(resolve, 400))
send(text)
}}
onEdit={(text) => {
setValue(text)
clear()
}}
onDiscard={clear}
/>What it catches
- Text left in the composer and never sent
- Who hasn't received it
- Accidental discard (two taps)
- Send failures, with an error
Props
| Prop | Type | Default |
|---|---|---|
text* (required)The unsent text. | string | — |
atWhen it was last typed (ms). | number | — |
variant`bubble` sits in the thread where the reply would be; `row` is a one-line preview for conversation lists. | "bubble" | "row" | "bubble" |
recipientWho it was meant for, e.g. "Priya". | string | — |
onSendSends it now. Return a promise to show a pending state; a rejection keeps the marker with an error. | (text: string) => void | Promise<void> | — |
onEditMoves it back into the composer. | (text: string) => void | — |
onDiscardThrows it away (asks once). | () => void | — |
notSentLabelLabel on the unsent bubble. | string | "Not sent" |
typedText`{when}` is filled in. | string | "Typed {when}." |
recipientText`{recipient}` is filled in. | string | "{recipient} hasn't received it." |
privateTextUsed when there's no `recipient`. | string | "Only you can see it." |
sendLabelLabel of the Send button. | string | "Send" |
editLabelLabel of the Edit button. | string | "Edit" |
discardLabelLabel of the Discard button. | string | "Discard" |
confirmDiscardLabelLabel of the second Discard tap. | string | "Discard draft?" |
failedTextShown when sending fails. | string | "Couldn't send. Check your connection and try again." |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Accessibility
- Bubble is a labelled group including the text
- Send failures use role=alert
- Buttons have text labels; icons are aria-hidden
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- Drafts are kept where your useUnsentDraft storage keeps them (per device)
- Doesn't know about sends from other devices unless you clear the draft
What it stops
Thought I replied but never sent it
How people describe it
Also known as
draft · unsent · chat · reply · message composer · whatsapp · inbox · conversation list
Field type