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
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/share-button.jsonPrompt
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
transitionDelayturns 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
inertand would otherwise strand the keyboard ondocument.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
inertplustabIndex={-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.