# Share Dialog

> A modal share dialog with copy link, email invite chips, access level and per-person roles.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/share-dialog
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/share-dialog.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/share-dialog-demo.tsx"
"use client"

import * as React from "react"
import { FileText, Share2 } from "lucide-react"

import { ShareDialog, type SharePerson } from "@/components/share-dialog"

const people: SharePerson[] = [
  { id: "ada", name: "Ada Lovelace", email: "ada@analytical.engine", role: "owner" },
  { id: "grace", name: "Grace Hopper", email: "grace@navy.mil", role: "editor" },
  { id: "alan", name: "Alan Turing", email: "alan@bletchley.uk", role: "commenter" },
]

export default function ShareDialogDemo() {
  const [open, setOpen] = React.useState(false)

  return (
    <div className="mx-auto flex w-full max-w-md flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
      <div className="flex min-w-0 items-start gap-3">
        <span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
          <FileText className="size-5" aria-hidden />
        </span>
        <div className="min-w-0 flex-1">
          <p className="truncate text-sm font-semibold">Q4 product roadmap</p>
          <p className="text-xs text-muted-foreground">Document · edited 12 minutes ago</p>
        </div>
        <button
          type="button"
          onClick={() => setOpen(true)}
          className="inline-flex h-9 shrink-0 items-center gap-2 rounded-lg bg-primary px-3.5 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none"
        >
          <Share2 className="size-4" aria-hidden /> Share
        </button>
      </div>
      <p className="text-sm text-muted-foreground">
        Three people can currently see this document. Open the share dialog to copy the link, invite more teammates, or change who can edit.
      </p>

      <ShareDialog
        open={open}
        onOpenChange={setOpen}
        resourceName="Q4 product roadmap"
        link="https://atelier.app/d/q4-roadmap-7f3a9c"
        people={people}
      />
    </div>
  )
}
```

## Source

```tsx title="components/share-dialog.tsx"
"use client"

import * as React from "react"
import { Check, ChevronDown, Copy, Globe, Link2, Lock, X } from "lucide-react"

import { cn } from "@/lib/utils"

export type ShareRole = "viewer" | "commenter" | "editor"
export type ShareAccess = "restricted" | "anyone"

export interface SharePerson {
  id: string
  name: string
  email: string
  role: ShareRole | "owner"
}

export interface ShareDialogProps extends Omit<React.ComponentProps<"dialog">, "open" | "title"> {
  open: boolean
  onOpenChange: (open: boolean) => void
  /** Name of the thing being shared, shown in the title */
  resourceName: string
  link: string
  /** People who already have access (initial value) */
  people?: SharePerson[]
  defaultAccess?: ShareAccess
  onInvite?: (emails: string[], role: ShareRole) => void
  onAccessChange?: (access: ShareAccess) => void
}

const roleLabels: Record<ShareRole, string> = { viewer: "Viewer", commenter: "Commenter", editor: "Editor" }
const emailPattern = /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/
const focusRing = "focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none"
const palette = ["bg-orange-500/15 text-orange-600", "bg-sky-500/15 text-sky-600", "bg-emerald-500/15 text-emerald-600", "bg-violet-500/15 text-violet-600", "bg-rose-500/15 text-rose-600"]

function initials(name: string) {
  return name
    .split(/[\s@._-]+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase())
    .join("")
}

function tone(seed: string) {
  let hash = 0
  for (const char of seed) hash = (hash * 31 + char.charCodeAt(0)) % 997
  return palette[hash % palette.length]
}

function nameFromEmail(email: string) {
  return email
    .split("@")[0]
    .split(/[._-]+/)
    .filter(Boolean)
    .map((part) => part[0].toUpperCase() + part.slice(1))
    .join(" ")
}

function Select({ className, children, ...props }: React.ComponentProps<"select">) {
  return (
    <span className={cn("relative inline-flex", className)}>
      <select
        {...props}
        className={cn(
          "h-9 w-full appearance-none rounded-lg border bg-background pr-8 pl-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/20 disabled:opacity-60"
        )}
      >
        {children}
      </select>
      <ChevronDown className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
    </span>
  )
}

// Built on <dialog>: showModal() gives us the top layer, an inert background,
// focus trapping, Escape handling and focus restoration to the opener.
export function ShareDialog({
  open,
  onOpenChange,
  resourceName,
  link,
  people: initialPeople = [],
  defaultAccess = "restricted",
  onInvite,
  onAccessChange,
  className,
  ...props
}: ShareDialogProps) {
  const ref = React.useRef<HTMLDialogElement>(null)
  const linkInput = React.useRef<HTMLInputElement>(null)
  const copyTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
  const titleId = React.useId()
  const descriptionId = React.useId()
  const errorId = React.useId()

  const [people, setPeople] = React.useState(initialPeople)
  const [access, setAccess] = React.useState<ShareAccess>(defaultAccess)
  const [linkRole, setLinkRole] = React.useState<ShareRole>("viewer")
  const [copied, setCopied] = React.useState(false)
  const [chips, setChips] = React.useState<string[]>([])
  const [draft, setDraft] = React.useState("")
  const [inviteRole, setInviteRole] = React.useState<ShareRole>("editor")
  const [error, setError] = React.useState("")
  const [announce, setAnnounce] = React.useState("")

  React.useEffect(() => {
    const dialog = ref.current
    if (!dialog) return
    if (open && !dialog.open) dialog.showModal()
    else if (!open && dialog.open) dialog.close()
  }, [open])

  React.useEffect(() => () => clearTimeout(copyTimer.current), [])

  async function copyLink() {
    try {
      await navigator.clipboard.writeText(link)
    } catch {
      const input = linkInput.current
      input?.select()
      try {
        document.execCommand("copy")
      } catch {
        // The text stays selected so people can copy it manually
      }
    }
    setCopied(true)
    setAnnounce("Link copied to clipboard")
    clearTimeout(copyTimer.current)
    copyTimer.current = setTimeout(() => setCopied(false), 2000)
  }

  // Splits raw text into valid addresses (new, de-duplicated) and the first invalid token.
  function parse(raw: string, existing: string[]) {
    const parts = raw.split(/[\s,;]+/).filter(Boolean)
    const seen = new Set([...existing, ...people.map((p) => p.email)].map((e) => e.toLowerCase()))
    const valid: string[] = []
    for (const part of parts) {
      if (!emailPattern.test(part) || seen.has(part.toLowerCase())) continue
      seen.add(part.toLowerCase())
      valid.push(part)
    }
    return { valid, invalid: parts.find((part) => !emailPattern.test(part)) ?? "" }
  }

  // Turns raw text into chips; returns the text that should stay in the input.
  function commit(raw: string) {
    const { valid, invalid } = parse(raw, chips)
    if (valid.length) setChips((list) => [...list, ...valid])
    setError(invalid ? "Enter a valid email address." : "")
    return invalid
  }

  function invite() {
    const { valid, invalid } = parse(draft, chips)
    if (invalid) {
      setError("Enter a valid email address.")
      return
    }
    const emails = [...chips, ...valid]
    if (emails.length === 0) return
    setPeople((list) => [
      ...list,
      ...emails.map((email, index) => ({ id: `${email}-${list.length + index}`, name: nameFromEmail(email), email, role: inviteRole })),
    ])
    setChips([])
    setDraft("")
    setError("")
    onInvite?.(emails, inviteRole)
    setAnnounce(`Invited ${emails.length} ${emails.length === 1 ? "person" : "people"} as ${roleLabels[inviteRole].toLowerCase()}`)
  }

  function onInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
    if (event.key === "Enter" || event.key === ",") {
      if (draft.trim()) {
        event.preventDefault()
        setDraft(commit(draft))
      } else if (event.key === "Enter" && chips.length) {
        event.preventDefault()
        invite()
      }
    } else if (event.key === "Backspace" && !draft && chips.length) {
      setChips((list) => list.slice(0, -1))
    }
  }

  function changeRole(person: SharePerson, value: string) {
    if (value === "remove") {
      setPeople((list) => list.filter((p) => p.id !== person.id))
      setAnnounce(`Removed ${person.name}`)
      return
    }
    setPeople((list) => list.map((p) => (p.id === person.id ? { ...p, role: value as ShareRole } : p)))
  }

  return (
    <dialog
      ref={ref}
      aria-modal="true"
      aria-labelledby={titleId}
      aria-describedby={descriptionId}
      onCancel={(event) => {
        event.preventDefault()
        onOpenChange(false)
      }}
      onClick={(event) => {
        if (event.target === event.currentTarget) onOpenChange(false)
      }}
      className={cn(
        "m-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-lg rounded-2xl border bg-background p-0 text-foreground shadow-2xl",
        "scale-95 opacity-0 transition-[opacity,scale,display,overlay] transition-discrete duration-200 ease-out open:scale-100 open:opacity-100 starting:open:scale-95 starting:open:opacity-0 motion-reduce:transition-none",
        "backdrop:bg-black/0 backdrop:transition-[background-color,display,overlay] backdrop:transition-discrete backdrop:duration-200 open:backdrop:bg-black/50 starting:open:backdrop:bg-black/0",
        className
      )}
      {...props}
    >
      <div className="flex flex-col gap-5 p-5 sm:p-6">
        <div className="flex flex-col gap-1 pr-8">
          <h2 id={titleId} className="text-lg leading-tight font-semibold">
            Share “{resourceName}”
          </h2>
          <p id={descriptionId} className="text-sm text-muted-foreground">
            Invite teammates or send a link. You can change access at any time.
          </p>
        </div>

        <div className="flex min-w-0 gap-2">
          <div className="relative min-w-0 flex-1">
            <Link2 className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
            <input
              ref={linkInput}
              readOnly
              value={link}
              aria-label="Share link"
              onFocus={(event) => event.currentTarget.select()}
              className="h-9 w-full min-w-0 truncate rounded-lg border bg-muted/50 pr-3 pl-9 text-sm text-muted-foreground outline-none focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/20"
            />
          </div>
          <button
            type="button"
            onClick={copyLink}
            className={cn(
              "inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors",
              focusRing,
              copied ? "bg-emerald-600 text-white" : "bg-primary text-primary-foreground hover:opacity-90"
            )}
          >
            {copied ? <Check className="size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
            {copied ? "Copied" : "Copy link"}
          </button>
        </div>

        <div className="grid gap-2">
          <div className="flex min-w-0 items-center gap-3">
            <span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
              {access === "anyone" ? <Globe className="size-4" aria-hidden /> : <Lock className="size-4" aria-hidden />}
            </span>
            <div className="min-w-0 flex-1">
              <label htmlFor={`${titleId}-access`} className="text-sm font-medium">
                General access
              </label>
              <p className="truncate text-xs text-muted-foreground">
                {access === "anyone" ? "Anyone with the link can open this" : "Only people you invite can open this"}
              </p>
            </div>
            <Select
              id={`${titleId}-access`}
              value={access}
              onChange={(event) => {
                const next = event.target.value as ShareAccess
                setAccess(next)
                onAccessChange?.(next)
              }}
              className="w-36 shrink-0"
            >
              <option value="restricted">Restricted</option>
              <option value="anyone">Anyone with link</option>
            </Select>
          </div>
          {access === "anyone" && (
            <div className="flex items-center justify-between gap-3 pl-12">
              <label htmlFor={`${titleId}-link-role`} className="text-xs text-muted-foreground">
                People with the link can
              </label>
              <Select id={`${titleId}-link-role`} value={linkRole} onChange={(event) => setLinkRole(event.target.value as ShareRole)} className="w-36 shrink-0">
                {Object.entries(roleLabels).map(([value, label]) => (
                  <option key={value} value={value}>
                    {label}
                  </option>
                ))}
              </Select>
            </div>
          )}
        </div>

        <div className="grid gap-2">
          <span className="text-sm font-medium">Invite by email</span>
          <div className="flex min-w-0 flex-col gap-2 sm:flex-row">
            <div
              onClick={(event) => event.currentTarget.querySelector("input")?.focus()}
              className={cn(
                "flex min-h-9 min-w-0 flex-1 flex-wrap items-center gap-1.5 rounded-lg border bg-background px-2 py-1 focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/20",
                error && "border-destructive focus-within:border-destructive focus-within:ring-destructive/20"
              )}
            >
              {chips.map((email) => (
                <span key={email} className="inline-flex max-w-full items-center gap-1 rounded-md bg-secondary py-0.5 pr-0.5 pl-2 text-xs font-medium text-secondary-foreground">
                  <span className="truncate">{email}</span>
                  <button
                    type="button"
                    aria-label={`Remove ${email}`}
                    onClick={() => setChips((list) => list.filter((e) => e !== email))}
                    className={cn("flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground", focusRing)}
                  >
                    <X className="size-3" aria-hidden />
                  </button>
                </span>
              ))}
              <input
                value={draft}
                onChange={(event) => {
                  setDraft(event.target.value)
                  if (error) setError("")
                }}
                onKeyDown={onInputKeyDown}
                onBlur={() => draft.trim() && setDraft(commit(draft))}
                onPaste={(event) => {
                  const text = event.clipboardData.getData("text")
                  if (/[\s,;]/.test(text.trim())) {
                    event.preventDefault()
                    setDraft(commit(`${draft} ${text}`))
                  }
                }}
                aria-label="Invite by email"
                aria-invalid={Boolean(error)}
                aria-describedby={error ? errorId : undefined}
                placeholder={chips.length ? "" : "Add people by email"}
                type="text"
                inputMode="email"
                autoComplete="off"
                className="h-7 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground"
              />
            </div>
            <div className="flex gap-2">
              <Select aria-label="Role for new people" value={inviteRole} onChange={(event) => setInviteRole(event.target.value as ShareRole)} className="w-32 flex-1 sm:flex-none">
                {Object.entries(roleLabels).map(([value, label]) => (
                  <option key={value} value={value}>
                    {label}
                  </option>
                ))}
              </Select>
              <button
                type="button"
                onClick={invite}
                disabled={chips.length === 0 && !draft.trim()}
                className={cn("inline-flex h-9 shrink-0 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50", focusRing)}
              >
                Invite
              </button>
            </div>
          </div>
          {error && (
            <p id={errorId} role="alert" className="text-xs text-destructive">
              {error}
            </p>
          )}
        </div>

        <div className="grid gap-2">
          <h3 className="text-sm font-medium">People with access</h3>
          <ul aria-label="People with access" className="grid max-h-48 gap-1 overflow-y-auto">
            {people.map((person) => (
              <li key={person.id} className="flex min-w-0 items-center gap-3 rounded-lg py-1.5">
                <span className={cn("flex size-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold", tone(person.email))}>{initials(person.name)}</span>
                <div className="min-w-0 flex-1">
                  <p className="truncate text-sm font-medium">{person.name}</p>
                  <p className="truncate text-xs text-muted-foreground">{person.email}</p>
                </div>
                {person.role === "owner" ? (
                  <span className="shrink-0 px-3 text-sm text-muted-foreground">Owner</span>
                ) : (
                  <Select aria-label={`Role for ${person.name}`} value={person.role} onChange={(event) => changeRole(person, event.target.value)} className="w-32 shrink-0">
                    {Object.entries(roleLabels).map(([value, label]) => (
                      <option key={value} value={value}>
                        {label}
                      </option>
                    ))}
                    <option value="remove">Remove access</option>
                  </Select>
                )}
              </li>
            ))}
          </ul>
        </div>

        <div className="flex justify-end">
          <button
            type="button"
            onClick={() => onOpenChange(false)}
            className={cn("inline-flex h-9 items-center justify-center rounded-lg border bg-background px-4 text-sm font-medium transition-colors hover:bg-accent", focusRing)}
          >
            Done
          </button>
        </div>
      </div>

      <button
        type="button"
        onClick={() => onOpenChange(false)}
        aria-label="Close"
        className={cn("absolute top-4 right-4 flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground", focusRing)}
      >
        <X className="size-4" aria-hidden />
      </button>
      <span role="status" aria-live="polite" className="sr-only">
        {announce}
      </span>
    </dialog>
  )
}
```
