Warns before someone relies on drafts or "Remember me" in a browser that won't keep them, after testing that storage really works.
StopsDrafts vanish in incognito
"use client"
import * as React from "react"
import { PrivateModeNotice, type StorageReport } from "@/components/ui/private-mode-notice"
const PRIVATE: StorageReport = { risk: "private", confidence: "likely", signals: ["storage quota is unusually small"] }
const field =
"w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
export function Demo() {
const [notes, setNotes] = React.useState(
"Day 2: ferry to Alibaug at 7:15, book the 4 pm return.\nAsk Meera about the homestay deposit.",
)
const [real, setReal] = React.useState(false)
const [found, setFound] = React.useState<string>()
const report = real ? undefined : PRIVATE
return (
<div className="w-full max-w-sm space-y-3">
<PrivateModeNotice
report={report}
hasUnsavedWork={notes.trim().length > 0}
exportData={() => notes}
exportFilename="trip-notes.txt"
onReport={(r) =>
real &&
setFound(r.risk === "ok" ? "This browser keeps data normally." : `This browser: ${r.risk} (${r.confidence}).`)
}
/>
<div className="space-y-3 rounded-xl border bg-background p-4">
<label className="block space-y-1.5">
<span className="text-sm font-medium">Trip notes (draft)</span>
<textarea rows={4} value={notes} onChange={(e) => setNotes(e.target.value)} className={field} />
</label>
<div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" defaultChecked className="size-4 accent-current" />
Remember me on this device
</label>
<PrivateModeNotice variant="inline" report={report} className="pl-6" />
</div>
</div>
<p className="text-xs text-muted-foreground">
{found ?? "Showing a simulated private window."}{" "}
{!real && (
<button
type="button"
onClick={() => setReal(true)}
className="rounded-sm underline underline-offset-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Check this browser
</button>
)}
</p>
</div>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/ui219 lines"use client" // Bollard UI — private-mode-notice · MIT licence · https://bollard-ui.vercel.app/components/private-mode-notice import * as React from "react" import { Check, Download, EyeOff, HardDrive, ShieldOff } from "lucide-react" import { cn } from "@/lib/utils" import { probeStorage, type StorageReport, type StorageRisk } from "./storage-probe" export * from "./storage-probe" /** Banner layout and tone shared by every Bollard UI banner. */ const bar = "flex min-h-11 flex-wrap items-center gap-x-3 gap-y-1.5 px-3 py-1.5 text-sm" const action = "inline-flex h-8 shrink-0 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 tone = { surface: "border-amber-500/30 bg-background bg-linear-to-r from-amber-500/10 to-amber-500/10 text-amber-950 dark:text-amber-50", icon: "text-amber-600 dark:text-amber-400", muted: "text-amber-900/75 dark:text-amber-100/75", solid: "bg-amber-700 text-white hover:bg-amber-800 dark:bg-amber-400 dark:text-amber-950 dark:hover:bg-amber-300", outline: "border border-amber-700/25 hover:bg-amber-500/15 dark:border-amber-300/25", } const icons = { private: EyeOff, blocked: ShieldOff, full: HardDrive } as const /** Runs `probeStorage` once after mount. Null until it resolves, or when `override` is given. */ export function useStorageReport(override?: StorageReport) { const [report, setReport] = React.useState<StorageReport | null>(null) React.useEffect(() => { if (override) return let live = true void probeStorage().then((result) => live && setReport(result)) return () => { live = false } }, [override]) return override ?? report } export interface PrivateModeNoticeProps extends Omit<React.ComponentProps<"div">, "children"> { /** Skip detection and use this report (tests, or your own server-side signal). */ report?: StorageReport /** `banner` above a form or editor; `inline` as a one-line hint under a "Remember me" box. */ variant?: "banner" | "inline" /** The user has work that only exists in this window. Strengthens the message and guards closing the tab. */ hasUnsavedWork?: boolean /** Returns the unsaved work to download. Without it there's no Export button. */ exportData?: () => string | Blob /** File name for the export. */ exportFilename?: string /** Called once detection finishes. */ onReport?: (report: StorageReport) => void /** Banner messages per risk. */ privateText?: string /** Shown when site data is blocked. */ blockedText?: string /** Shown when storage is nearly full. */ fullText?: string /** Appended when work is at stake. */ unsavedText?: string /** Shown when the risk is inferred, not proven. */ likelyText?: string /** One-line messages for the inline variant. */ inlinePrivateText?: string /** Inline variant text for blocked storage. */ inlineBlockedText?: string /** Inline variant text for full storage. */ inlineFullText?: string /** Button text to download unsaved work. */ exportLabel?: string /** Button text after exporting. */ exportedLabel?: string /** Button text to dismiss the notice. */ dismissLabel?: string } /** * Warns before someone trusts drafts or "Remember me" in a browser that won't * keep them: a private window (erased on close), blocked site data (not kept * even across a reload) or a full quota (new saves fail). A real write test * runs first, so it only says "probably" when it's inferring; it offers to * export unsaved work and guards closing the tab while work is at stake. */ export function PrivateModeNotice({ report: reportProp, variant = "banner", hasUnsavedWork = false, exportData, exportFilename = "unsaved-work.txt", onReport, privateText = "This is a private window. Drafts and “Remember me” are erased when you close it.", blockedText = "Your browser is blocking site storage. Drafts and “Remember me” won’t survive a reload.", fullText = "Storage for this site is nearly full. New drafts may not save.", unsavedText = "Your unsaved work only exists in this window.", likelyText = "Detected from how your browser stores data, so it may be wrong.", inlinePrivateText = "Private window: you’ll be signed out when you close it.", inlineBlockedText = "Your browser blocks site storage, so this can’t be remembered.", inlineFullText = "Browser storage is full, so this may not be remembered.", exportLabel = "Export", exportedLabel = "Downloaded", dismissLabel = "Got it", className, ...props }: PrivateModeNoticeProps) { const report = useStorageReport(reportProp) const [dismissed, setDismissed] = React.useState(false) const [exported, setExported] = React.useState(false) const risk: StorageRisk = report?.risk ?? "ok" const atRisk = risk !== "ok" const onReportRef = React.useRef(onReport) React.useEffect(() => { onReportRef.current = onReport }) React.useEffect(() => { if (report) onReportRef.current?.(report) }, [report]) // Closing a private (or storage-blocked) window throws the work away for good. React.useEffect(() => { if (!hasUnsavedWork || (risk !== "private" && risk !== "blocked")) return const guard = (event: BeforeUnloadEvent) => event.preventDefault() window.addEventListener("beforeunload", guard) return () => window.removeEventListener("beforeunload", guard) }, [hasUnsavedWork, risk]) React.useEffect(() => { if (!exported) return const timer = setTimeout(() => setExported(false), 2000) return () => clearTimeout(timer) }, [exported]) const download = () => { if (!exportData) return const data = exportData() const url = URL.createObjectURL( data instanceof Blob ? data : new Blob([data], { type: "text/plain;charset=utf-8" }), ) const link = Object.assign(document.createElement("a"), { href: url, download: exportFilename }) link.click() setTimeout(() => URL.revokeObjectURL(url), 0) setExported(true) } const Icon = atRisk ? icons[risk as Exclude<StorageRisk, "ok">] : EyeOff if (variant === "inline") { const text = { ok: "", private: inlinePrivateText, blocked: inlineBlockedText, full: inlineFullText }[risk] return ( <div data-slot="private-mode-notice" data-risk={risk} aria-live="polite" className={cn("text-xs empty:hidden", className)} {...props} > {atRisk && ( <p className="mt-1.5 flex items-start gap-1.5 text-amber-700 dark:text-amber-400"> <Icon aria-hidden className="mt-px size-3.5 shrink-0" /> {text} </p> )} </div> ) } const message = { ok: "", private: privateText, blocked: blockedText, full: fullText }[risk] const visible = atRisk && !dismissed return ( <div data-slot="private-mode-notice" data-risk={risk} aria-live="polite" className={cn(!visible && "hidden", className)} {...props} > {visible && ( <div role="region" aria-label={message} className={cn( "overflow-hidden rounded-lg border motion-safe:animate-[cui-private-mode-notice-in_250ms_ease-out]", tone.surface, )} > <style>{"@keyframes cui-private-mode-notice-in{from{opacity:0;transform:translateY(-4px)}}"}</style> <div className={bar}> <div className="flex min-w-0 flex-1 basis-56 gap-3"> <Icon aria-hidden className={cn("mt-0.5 size-4 shrink-0", tone.icon)} /> <div className="min-w-0 flex-1 py-0.5"> <p className="font-medium">{message}</p> {(hasUnsavedWork || report?.confidence === "likely") && ( <p className={cn("text-xs", tone.muted)}> {[hasUnsavedWork && unsavedText, report?.confidence === "likely" && likelyText] .filter(Boolean) .join(" ")} </p> )} </div> </div> <div className="ml-auto flex gap-2"> <button type="button" onClick={() => setDismissed(true)} className={cn(action, tone.outline)}> {dismissLabel} </button> {exportData && ( <button type="button" onClick={download} className={cn(action, tone.solid)}> {exported ? <Check aria-hidden className="size-4" /> : <Download aria-hidden className="size-4" />} {exported ? exportedLabel : exportLabel} </button> )} </div> </div> </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 { PrivateModeNotice, type StorageReport } from "@/components/ui/private-mode-notice"
<PrivateModeNotice
report={report}
hasUnsavedWork={notes.trim().length > 0}
exportData={() => notes}
exportFilename="trip-notes.txt"
onReport={(r) =>
real &&
setFound(r.risk === "ok" ? "This browser keeps data normally." : `This browser: ${r.risk} (${r.confidence}).`)
}
/>What it catches
- Private window that erases saved data on close
- Site data blocked, so nothing can be saved
- Storage nearly full, so new saves may fail
- Unsaved work at risk: export button
Props
| Prop | Type | Default |
|---|---|---|
reportSkip detection and use this report (tests, or your own server-side signal). | StorageReport | — |
variant`banner` above a form or editor; `inline` as a one-line hint under a "Remember me" box. | "banner" | "inline" | "banner" |
hasUnsavedWorkThe user has work that only exists in this window. Strengthens the message and guards closing the tab. | boolean | false |
exportDataReturns the unsaved work to download. Without it there's no Export button. | () => string | Blob | — |
exportFilenameFile name for the export. | string | "unsaved-work.txt" |
onReportCalled once detection finishes. | (report: StorageReport) => void | — |
privateTextBanner messages per risk. | string | "This is a private window. Drafts and “Remember me” are e… |
blockedTextShown when site data is blocked. | string | "Your browser is blocking site storage. Drafts and “Remem… |
fullTextShown when storage is nearly full. | string | "Storage for this site is nearly full. New drafts may not… |
unsavedTextAppended when work is at stake. | string | "Your unsaved work only exists in this window." |
likelyTextShown when the risk is inferred, not proven. | string | "Detected from how your browser stores data, so it may be… |
inlinePrivateTextOne-line messages for the inline variant. | string | "Private window: you’ll be signed out when you close it." |
inlineBlockedTextInline variant text for blocked storage. | string | "Your browser blocks site storage, so this can’t be remem… |
inlineFullTextInline variant text for full storage. | string | "Browser storage is full, so this may not be remembered." |
exportLabelButton text to download unsaved work. | string | "Export" |
exportedLabelButton text after exporting. | string | "Downloaded" |
dismissLabelButton text to dismiss the notice. | string | "Got it" |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Accessibility
- Notice is a labelled region inside a polite live region
- Export and dismiss are buttons; icons are aria-hidden
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- Private-mode detection is sometimes inferred ("likely") when a write succeeds
- Browsers change storage behaviour often
- Export uses the data you pass in exportData
What it stops
Drafts vanish in incognito
How people describe it
Also known as
incognito · private browsing · localstorage · remember me · storage quota · drafts · cookies blocked · safari private