Buttons

Share Button

A share trigger whose targets fan out on an arc or a rail, one 40ms step apart, with outside-click and Esc dismissal.

Preview in your theme

Loading preview…

Dependencies
lucide-react
Registry dependencies
utils, use-click-outside, use-controllable-state
"use client"

import * as React from "react"
import { Share2, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useClickOutside } from "@/hooks/use-click-outside"
import { useControllableState } from "@/hooks/use-controllable-state"

export type ShareDirection = "up" | "right" | "arc"

export interface ShareAction {
  /** Stable identity for React keys. */
  key: string
  /** Accessible name of the satellite button — "Copy link", "Email", … */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/share-button.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ShareButton" component using
lucide-react icons. No popover library — the fan is plain transforms.

Contract
- Shared primitives (installed automatically via registryDependencies):
  use-controllable-state runs the open / defaultOpen / onOpenChange triad, and
  use-click-outside owns the outside-press dismissal. If rebuilding without
  them: keep an inner state used only while `open` is undefined and call
  onOpenChange on every change; add a document "pointerdown" listener while
  open that closes on presses outside the wrapper, reading the handler from a
  ref so an inline onOpenChange arrow does not resubscribe it every render.
- Export a forwardRef <div> wrapper extending React.HTMLAttributes plus:
  actions: { key: string; label: string; icon: ReactNode; onSelect: () => void }[]
  direction?: "up" | "right" | "arc"   (default "arc")
  open?: boolean                        controlled disclosure
  defaultOpen?: boolean                 uncontrolled seed
  onOpenChange?: (open: boolean) => void
  label?: string                        accessible name of the trigger
- The component owns *disclosure only*. Every real share behaviour (clipboard
  write, mailto:, Web Share API, analytics) lives in the consumer's onSelect;
  the menu closes right after it fires and focus goes back to the trigger.
- Consumer onKeyDown still fires before the internal Esc/arrow handling.

Behavior
- Geometry is computed, not hardcoded per item. A pure offsetFor(direction,
  index, total) returns {x, y}:
    up    → (0, -(index + 1) * step)
    right → ((index + 1) * step, 0)
    arc   → sweep symmetrically around straight-up across ~120°, radius ~68px;
            a single action sits dead centre.
  Satellites are absolutely positioned inside a zero-size anchor pinned to the
  trigger centre, so the transform is
  translate(-50%,-50%) translate(x,y) scale(1) when open and
  translate(-50%,-50%) scale(0.4) when closed.
- Stagger: transitionDelay = index * 40ms on open and reversed
  ((total - 1 - index) * 40ms) on close, so the fan retracts outside-in.
- Dismissal: while open, a document "pointerdown" listener (use-click-outside,
  enabled only while open) closes on any press outside the wrapper and is
  removed the moment it closes or the component unmounts. Escape closes and returns focus to the trigger.
- Every close that starts from inside the fan — Escape *and* picking a target —
  moves focus to the trigger first. Collapsing makes the satellite layer `inert`,
  which blurs whatever it contains, so a keyboard user who activates a satellite
  would otherwise be dropped on document.body.
- Keyboard: satellites are tabbable while open (tabIndex 0) and arrow keys rove
  between them with wraparound. "Forward" means away from the trigger:
  ArrowDown / ArrowRight for "arc" and "right", ArrowUp / ArrowRight for "up"
  (the upward stack grows upward, so its arrows are flipped). While collapsed
  the whole menu layer is `inert` and tabIndex -1, so it is out of the tab
  order and the accessibility tree without unmounting (it still has to animate
  out).
- Menu-button entry: opening from the trigger (click, Enter, Space, or an arrow
  key) moves focus onto the fan in an effect keyed on the open state — the
  first satellite, or the last for a backward arrow — because the layer is
  only non-inert after that commit. A pending-edge ref records that the open
  came from the trigger, so an open driven by the `open` prop or defaultOpen
  never steals focus. With the fan open and focus on the trigger, arrow keys
  jump to the first / last satellite. The root ref merges the forwarded ref
  in a useCallback so the consumer's callback ref is not detached and
  re-attached on every render.
- The trigger cross-fades a Share glyph into an X with a quarter turn.
- prefers-reduced-motion: motion-reduce:transition-none on satellites and the
  trigger glyphs. With no transition the stagger delay is inert too, so the fan
  simply appears and disappears — dismissal, focus and selection unchanged.

Rendering & styling
- Semantic tokens only: bg-primary + text-primary-foreground for the trigger,
  border + bg-card + text-foreground for satellites with
  hover:bg-accent hover:text-accent-foreground, ring-ring for focus.
- Trigger gets aria-expanded + aria-haspopup="menu" + aria-label; the anchor is
  role="menu" with the same label; satellites are role="menuitem" named by
  action.label with the icon aria-hidden.
- The wrapper is `relative inline-flex` and the fan deliberately overflows it —
  callers must leave room (the demo gives each cell a fixed-height box).
  Merge the consumer className through cn().

Customization levers
- Geometry: LINEAR_STEP (52px), ARC_RADIUS (68px) and ARC_SPREAD (120°) are the
  only numbers that shape the fan; widen the spread as you add targets so the
  satellites keep a full diameter of clearance.
- Rhythm: the 40ms stagger and the 300ms duration — keep total
  (n * stagger + duration) under ~500ms or the last target feels late.
- Add a direction: one more branch in offsetFor, nothing else changes.
- Density: swap the size-12 trigger / size-10 satellites pair for a smaller set
  when the button sits inside a toolbar rather than on a card.
- Labels: render action.label next to the icon for the linear directions if the
  targets are not universally recognisable as glyphs.

Concepts

  • Fan-out as one gesture — the targets travel from under the trigger to their resting positions, so the origin of the menu is never in doubt and no second focus target appears elsewhere on screen.
  • Staggered entrance — a per-index transitionDelay turns a simultaneous pop into a sweep; reversing the index on close makes the retraction read as the same motion played backwards.
  • Computed geometry — one pure function maps (direction, index, total) to an offset, so adding a target or a new layout never means hand-placing anything.
  • Dismissal contract — the outside-press listener exists only while open and is torn down on close or unmount; every close that starts from inside the fan (Esc, or picking a target) hands focus back to the trigger, because the collapse itself makes the satellite layer inert and would otherwise strand the keyboard on document.body.
  • Menu-button entry — a trigger that announces aria-haspopup="menu" has to put focus inside the menu when it opens, otherwise the arrow-key navigation it promises is unreachable; only opens started on the trigger do this, so a parent opening the fan programmatically keeps focus where it was.
  • Inert while collapsed — hidden satellites stay mounted so they can animate out, but inert plus tabIndex={-1} keeps them out of both the tab order and the accessibility tree.
  • Disclosure, not behaviour — the component decides when targets are reachable; what a target does is entirely the consumer's onSelect, which keeps clipboard, mailto and Web Share concerns out of the UI layer.

On This Page