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…
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
- 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 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. 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).
- 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. - 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.