Skip to content
Product block

Share & Access Dialog

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.

312 lineslucide-reactAdded 25 Aug 2026Updated 2 Sept 2026
  • share
  • permissions
  • dialog
  • access
  • collaboration

What's included

  • components/share-access-dialog.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add share-access-dialog

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

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

Source

components/share-access-dialog.tsx
'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 &ldquo;{documentName}&rdquo;
          </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>
  )
}

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/share-access-dialog.tsx and import it where you need the section:

import { ShareAccessDialog } from '@/components/share-access-dialog'

Customize

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.

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
documentNamestring'Q4 pricing model'
organisationNamestring'Northwind'
peopleSharePerson[]DEFAULT_PEOPLE
linkstring'https://app.example.com/d/q4-pricing-model'
accessGeneralAccess'restricted'
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.

share-access-dialog.html
<!--
  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>
  • 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

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

7 more blocks in Modals & Drawers

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 & Drawers

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 Modals & Drawers blocks

View category
Open the full page for this block

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.

Modals & Drawers184 lines1 dep
Open the full page for this block

Cookie Consent Banner

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.

Modals & Drawers232 lines1 dep
Open the full page for this block

Slide-Over Edit Panel

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.

Modals & Drawers208 lines1 dep
Open the full page for this block

Faceted Filter Drawer

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.

Modals & Drawers241 lines1 dep
Open the full page for this block

Mobile Bottom Sheet

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.

Modals & Drawers266 lines1 dep
Open the full page for this block

Unsaved Changes Prompt

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.

Modals & Drawers237 lines1 dep
Open the full page for this block

Record Inspector Drawer

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.

Modals & Drawers336 lines1 dep