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…

"use client"

import * as React from "react"
import { Share2, X } from "lucide-react"
import { cn } from "@/lib/utils"

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", … */
  label: string
  /** Rendered inside the satellite; size it to ~16px (e.g. <Mail className="size-4" />). */

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
- 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 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.
  • 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