Destructive Confirm Dialog
A type-to-confirm dialog built on native <dialog>, so the focus trap, top layer and escape handling come from the browser. Focus lands on Cancel, and the button says the verb.
The share modal where a design mistake is a data leak: general access stated as its consequence rather than its setting, and a copy button that never silently widens access.
components/share-access-dialog.tsxnpx hoverlab add share-access-dialog
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.
Only the people listed below can open it. Anyone else following the link is asked to request access.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <ShareAccessDialog> — who can see this, and what the link does.
*
* Modals & Drawers had a destructive confirm, a cookie banner, an edit
* panel and a filter drawer. Sharing is the modal every collaborative
* product has and the one where a design mistake becomes a data leak
* rather than an annoyance.
*
* THE ONE THING THIS GETS RIGHT THAT MOST DO NOT
*
* The general-access control states the consequence, not the setting.
* "Anyone with the link" is a configuration value; "Anyone with the link
* can view — it will not require signing in" is what is actually true, and
* it is the sentence that stops somebody picking it for a document they
* thought was internal. The current consequence is restated in plain words
* under the control, always, not only when it changes.
*
* COPY LINK DOES NOT SILENTLY GRANT ACCESS
*
* The trap in this pattern: user clicks "Copy link" on a restricted
* document, pastes it to a colleague, and the colleague hits a permission
* wall. Some products "helpfully" widen access on copy, which is worse —
* it silently publishes a document because somebody used the clipboard. So
* the button copies, and when the link would not work for the recipient it
* says so next to itself and offers the change as an explicit action.
*
* ROLE CHANGES ARE PER-PERSON AND IMMEDIATE
*
* No save button. A share dialog with unsaved state is how people close it
* believing they revoked someone. Each select applies on change, which is
* also why removing is a separate destructive-styled item in the same menu
* rather than a stray X that can be mis-clicked.
*
* THE OWNER CANNOT BE DEMOTED HERE
*
* Rendered as text rather than a disabled select. A greyed control invites
* people to try, and then to look for the workaround; a plain "Owner"
* says the transfer lives somewhere else.
*
* ACCESSIBILITY: `role="dialog"` with `aria-modal`, labelled by its own
* heading. Every per-person select is labelled with the person's name, so
* a screen reader hears "Tom Okafor, permission, Editor" rather than five
* identical "permission" comboboxes.
*/
import * as React from 'react'
import { Check, Copy, Globe, Link2, Lock, Users } from 'lucide-react'
export type GeneralAccess = 'restricted' | 'organisation' | 'public'
export interface SharePerson {
name: string
email: string
initials: string
role: 'owner' | 'editor' | 'commenter' | 'viewer'
}
export interface ShareAccessDialogProps {
documentName?: string
organisationName?: string
people?: SharePerson[]
link?: string
access?: GeneralAccess
className?: string
}
const DEFAULT_PEOPLE: SharePerson[] = [
{ name: 'Priya Raman', email: 'priya@northwind.com', initials: 'PR', role: 'owner' },
{ name: 'Tom Okafor', email: 'tom@northwind.com', initials: 'TO', role: 'editor' },
{ name: 'Ines Duarte', email: 'ines@contractor.io', initials: 'ID', role: 'commenter' },
]
const ROLE_LABELS: Record<SharePerson['role'], string> = {
owner: 'Owner',
editor: 'Editor',
commenter: 'Commenter',
viewer: 'Viewer',
}
/*
Consequences, not settings. Each option says what will actually be true
of the document if it is chosen.
*/
const ACCESS_COPY: Record<
GeneralAccess,
{ label: string; consequence: string; icon: React.ComponentType<{ className?: string }> }
> = {
restricted: {
label: 'Restricted',
consequence:
'Only the people listed below can open it. Anyone else following the link is asked to request access.',
icon: Lock,
},
organisation: {
label: 'Anyone at Northwind',
consequence:
'Anyone signed in with a Northwind account can open it, including people who join later.',
icon: Users,
},
public: {
label: 'Anyone with the link',
consequence:
'Anyone who has the link can open it without signing in, and can pass it on. It may be indexed by search engines.',
icon: Globe,
},
}
export function ShareAccessDialog({
documentName = 'Q4 pricing model',
organisationName = 'Northwind',
people = DEFAULT_PEOPLE,
link = 'https://app.example.com/d/q4-pricing-model',
access = 'restricted',
className = '',
}: ShareAccessDialogProps) {
/*
Per-instance prefix for every id this block emits.
The literals these replaced were a latent duplicate the moment the
block appeared twice on one document, and `aria-labelledby` on a
duplicated id resolves to the first match -- so the second copy was
labelled by the first copy's heading. Client component, so `useId` is
the right tool.
*/
const uid = React.useId()
const [general, setGeneral] = React.useState<GeneralAccess>(access)
const [roles, setRoles] = React.useState(() =>
Object.fromEntries(people.map((p) => [p.email, p.role])),
)
const [copied, setCopied] = React.useState(false)
// The refusal path needs its own state: a button that silently does
// nothing is the failure mode this replaced.
const [failed, setFailed] = React.useState(false)
const copy = ACCESS_COPY[general]
const Icon = copy.icon
/* The honest warning that replaces silently widening access on copy. */
const linkIsPrivate = general === 'restricted'
return (
<section className={`mx-auto w-full max-w-lg px-4 py-16 sm:px-6 ${className}`}>
<div
role="dialog"
aria-modal="true"
aria-labelledby={`${uid}-share-heading`}
className="rounded-2xl border border-border bg-card shadow-lg"
>
<header className="border-b border-border px-5 py-4">
<h2 id={`${uid}-share-heading`} className="truncate text-base font-semibold text-foreground">
Share “{documentName}”
</h2>
</header>
<div className="px-5 py-4">
<label className="block">
<span className="sr-only">Invite by email</span>
<input
type="email"
placeholder="Add people by email"
className="h-9 w-full rounded-lg border border-field bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</label>
<ul className="mt-4 space-y-3">
{people.map((person) => {
const role = roles[person.email] ?? person.role
const selectId = `share-role-${person.email.replace(/\W+/g, '-')}`
return (
<li key={person.email} className="flex items-center gap-3">
<span
aria-hidden
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground"
>
{person.initials}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">
{person.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{person.email}
</span>
</span>
{person.role === 'owner' ? (
/* Text, not a disabled control — see the note above. */
<span className="max-w-[8rem] shrink-0 truncate pe-3 text-sm text-muted-foreground">
Owner
</span>
) : (
<>
<label htmlFor={selectId} className="sr-only">
Permission for {person.name}
</label>
<select
id={selectId}
value={role}
onChange={(event) =>
setRoles((current) => ({
...current,
[person.email]: event.target.value as SharePerson['role'],
}))
}
className="h-8 shrink-0 rounded-lg border border-field bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<option value="editor">{ROLE_LABELS.editor}</option>
<option value="commenter">{ROLE_LABELS.commenter}</option>
<option value="viewer">{ROLE_LABELS.viewer}</option>
<option value="remove">Remove access</option>
</select>
</>
)}
</li>
)
})}
</ul>
</div>
<div className="border-t border-border px-5 py-4">
<h3 data-stress-ignore className="text-sm font-medium text-foreground">General access</h3>
<div className="mt-2 flex items-center gap-2">
<span
aria-hidden
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"
>
<Icon className="h-4 w-4" />
</span>
<label className="min-w-0 flex-1">
<span className="sr-only">Who can open this document</span>
<select
value={general}
onChange={(event) => setGeneral(event.target.value as GeneralAccess)}
className="h-8 w-full rounded-lg border border-field bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<option value="restricted">{ACCESS_COPY.restricted.label}</option>
<option value="organisation">Anyone at {organisationName}</option>
<option value="public">{ACCESS_COPY.public.label}</option>
</select>
</label>
</div>
{/* Always shown, not only on change. */}
<p role="status" className="mt-2 text-sm text-muted-foreground">
{copy.consequence}
</p>
</div>
<footer className="flex flex-wrap items-center gap-3 border-t border-border px-5 py-4">
<button
type="button"
onClick={() => {
/*
* Confirm on success, not on click.
*
* The clipboard API rejects for reasons a component cannot
* predict — permission denied, an insecure origin, a
* document that is not focused. The previous version fired
* the write and set "Link copied" unconditionally, so a
* refused write showed a tick and an unhandled rejection in
* the console while the clipboard still held whatever was
* there before.
*/
navigator.clipboard
?.writeText(link)
.then(() => {
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
})
.catch(() => {
setFailed(true)
window.setTimeout(() => setFailed(false), 3000)
})
}}
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border bg-background px-3 text-sm font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{copied ? (
<Check aria-hidden className="h-4 w-4" />
) : (
<Link2 aria-hidden className="h-4 w-4" />
)}
{copied ? 'Link copied' : failed ? 'Copy blocked' : 'Copy link'}
</button>
<button
type="button"
className="ms-auto inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition 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"
>
Done
</button>
{/*
Said, never done silently. Copying must not widen access — that
would publish a document because somebody used the clipboard.
*/}
{linkIsPrivate ? (
<p className="w-full text-xs text-muted-foreground">
<Copy aria-hidden className="me-1 inline h-3 w-3" />
This link only works for the people listed above. Anyone else will
have to request access — change General access if you meant to
share it more widely.
</p>
) : null}
</footer>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/share-access-dialog.tsx and import it where you need the section:
import { ShareAccessDialog } from '@/components/share-access-dialog'3 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 |
|---|---|---|
documentName | string | 'Q4 pricing model' |
organisationName | string | 'Northwind' |
people | SharePerson[] | DEFAULT_PEOPLE |
link | string | 'https://app.example.com/d/q4-pricing-model' |
access | GeneralAccess | 'restricted' |
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.
<!--
Share & Access Dialog — 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="mx-auto w-full max-w-lg px-4 py-16 sm:px-6 ">
<div role="dialog" aria-modal="true" aria-labelledby="_R_0_-share-heading" class="rounded-2xl border border-border bg-card shadow-lg">
<header class="border-b border-border px-5 py-4">
<h2 id="_R_0_-share-heading" class="truncate text-base font-semibold text-foreground">Share “Q4 pricing model”</h2>
</header>
<div class="px-5 py-4">
<label class="block">
<span class="sr-only">Invite by email</span>
<input type="email" placeholder="Add people by email" class="h-9 w-full rounded-lg border border-field bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" />
</label>
<ul class="mt-4 space-y-3">
<li class="flex items-center gap-3">
<span aria-hidden="true" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground">PR</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-foreground">Priya Raman</span>
<span class="block truncate text-xs text-muted-foreground">priya@northwind.com</span>
</span>
<span class="max-w-[8rem] shrink-0 truncate pe-3 text-sm text-muted-foreground">Owner</span>
</li>
<li class="flex items-center gap-3">
<span aria-hidden="true" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground">TO</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-foreground">Tom Okafor</span>
<span class="block truncate text-xs text-muted-foreground">tom@northwind.com</span>
</span>
<label for="share-role-tom-northwind-com" class="sr-only">Permission for Tom Okafor</label>
<select id="share-role-tom-northwind-com" class="h-8 shrink-0 rounded-lg border border-field bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
<option value="editor" selected="">Editor</option>
<option value="commenter">Commenter</option>
<option value="viewer">Viewer</option>
<option value="remove">Remove access</option>
</select>
</li>
<li class="flex items-center gap-3">
<span aria-hidden="true" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground">ID</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-foreground">Ines Duarte</span>
<span class="block truncate text-xs text-muted-foreground">ines@contractor.io</span>
</span>
<label for="share-role-ines-contractor-io" class="sr-only">Permission for Ines Duarte</label>
<select id="share-role-ines-contractor-io" class="h-8 shrink-0 rounded-lg border border-field bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
<option value="editor">Editor</option>
<option value="commenter" selected="">Commenter</option>
<option value="viewer">Viewer</option>
<option value="remove">Remove access</option>
</select>
</li>
</ul>
</div>
<div class="border-t border-border px-5 py-4">
<h3 data-stress-ignore="true" class="text-sm font-medium text-foreground">General access</h3>
<div class="mt-2 flex items-center gap-2">
<span aria-hidden="true" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted 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-lock h-4 w-4" 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>
</span>
<label class="min-w-0 flex-1">
<span class="sr-only">Who can open this document</span>
<select class="h-8 w-full rounded-lg border border-field bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
<option value="restricted" selected="">Restricted</option>
<option value="organisation">Anyone at Northwind</option>
<option value="public">Anyone with the link</option>
</select>
</label>
</div>
<p role="status" class="mt-2 text-sm text-muted-foreground">Only the people listed below can open it. Anyone else following the link is asked to request access.</p>
</div>
<footer class="flex flex-wrap items-center gap-3 border-t border-border px-5 py-4">
<button type="button" class="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border bg-background px-3 text-sm font-medium text-foreground transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><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-link2 lucide-link-2 h-4 w-4" aria-hidden="true"><path d="M9 17H7A5 5 0 0 1 7 7h2"></path><path d="M15 7h2a5 5 0 1 1 0 10h-2"></path><line x1="8" x2="16" y1="12" y2="12"></line></svg>Copy link</button>
<button type="button" class="ms-auto inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition 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">Done</button>
<p class="w-full 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-copy me-1 inline h-3 w-3" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>This link only works for the people listed above. Anyone else will have to request access — change General access if you meant to share it more widely.</p>
</footer>
</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.
Want the whole screen instead of this one section? Open a page and copy it entire.
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 Modals & DrawersCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
A type-to-confirm dialog built on native <dialog>, so the focus trap, top layer and escape handling come from the browser. Focus lands on Cancel, and the button says the verb.
Per-category consent where refusing is exactly as easy as accepting, nothing non-essential is pre-ticked, and the page behind it stays readable — the shape the law expects, wired to your own script gating.
A side sheet for editing a record without losing the list behind it — native <dialog> for the trapping, a data attribute for the slide, and a sticky footer so Save never scrolls away.
Facets with per-option counts, zero-result options disabled rather than hidden so the list never jumps, and a pending selection applied on a button that says how many choices changed.
Half and full snap points where the content owns the scroll and overscroll-contain keeps the page behind still. The drag handle is a real button, Escape and the backdrop close it, and safe-area padding keeps the last row pressable.
The interception that keeps work: three answers rather than two, the changed fields listed by name, a destructive-styled discard, and Escape resolving to cancel.
A non-modal drawer that keeps the list on screen, with previous and next controls for moving between records and an activity tab beside the fields.