Skip to content

Share Button Expand

A share button that springs open into a row of targets: X, LinkedIn, email and a copy-link that only confirms when the clipboard write worked. A light follows the pointer, a lens slides between options, and the device share sheet joins in where it exists. No dependencies.

DOM + CSS0 deps1 file · 20 KB TSXreduced-motion aware

Freeminimalplayful
Preview

Props

LinkedIn
Email
No runtime dependencies
"use client";

import { useEffect, useRef, useState, useSyncExternalStore } from "react";

export type ShareTarget = "x" | "linkedin" | "email" | "copy";

export interface ShareButtonExpandProps {
  /** The link to share. Defaults to the current page address. */
  url?: string;
  /** Subject for email, and the post text when `text` is empty. */
  title?: string;
  /** Short post text for X and the email body. */
  text?: string;
  /** Which share targets to show, in order. */
  targets?: ShareTarget[];
  /** Add a "More" button that opens the device share sheet when the browser has one. */
  native?: boolean;
  /** Start expanded. */
  defaultOpen?: boolean;
  /** "auto" inherits the page text colour, "light" and "dark" paint their own surface. */
  tone?: "auto" | "light" | "dark";
  /** Hover, focus and success colour. */
  accent?: string;
  onOpenChange?: (open: boolean) => void;
  /** Called after a target is used. For "copy" it only fires when the copy worked. */
  onShare?: (target: ShareTarget | "native") => void;
  className?: string;
}

const DEFAULT_TARGETS: ShareTarget[] = ["x", "email", "copy"];
const ACCENT_RE = /^(#[0-9a-f]{3,8}|(rgb|hsl)a?\([0-9a-z%.,\s/-]+\))$/i;

const CSS = `
.sbx-root{--sbx-ease:cubic-bezier(.34,1.56,.64,1);--sbx-soft:cubic-bezier(.22,1,.36,1);color:var(--sbx-text,inherit)}
.sbx-tone-auto{--sbx-fg:color-mix(in srgb,currentColor 62%,transparent);--sbx-strong:currentColor;--sbx-bd:color-mix(in srgb,currentColor 17%,transparent);--sbx-bg:color-mix(in srgb,currentColor 4%,transparent);--sbx-sh:none}
.sbx-tone-light{--sbx-text:#525252;--sbx-fg:#6b6b6b;--sbx-strong:#171717;--sbx-bd:#e5e5e5;--sbx-bg:#fff;--sbx-sh:0 1px 2px rgba(0,0,0,.05),0 14px 28px -16px rgba(0,0,0,.22)}
.sbx-tone-dark{--sbx-text:#e5e7eb;--sbx-fg:rgba(255,255,255,.62);--sbx-strong:#fff;--sbx-bd:rgba(255,255,255,.12);--sbx-bg:#0d0e11;--sbx-sh:inset 0 1px 0 rgba(255,255,255,.06),0 18px 36px -18px rgba(0,0,0,.9)}
.sbx-pill{position:relative;display:inline-flex;align-items:center;padding:.25em;border-radius:999px;border:1px solid var(--sbx-bd);background:var(--sbx-bg);box-shadow:var(--sbx-sh);border-color:var(--sbx-bd)}
.sbx-pill::before{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0;transition:opacity .35s ease;background:radial-gradient(9em circle at var(--sbx-mx,50%) var(--sbx-my,0%),color-mix(in srgb,var(--sbx-accent) 20%,transparent),transparent 70%)}
.sbx-pill:hover::before{opacity:1}
.sbx-btn{position:relative;display:inline-flex;align-items:center;justify-content:center;flex:none;width:2.5em;height:2.5em;padding:0;margin:0;border:0;border-radius:999px;background:transparent;color:var(--sbx-fg);font:inherit;line-height:1;text-decoration:none;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:color .2s ease}
.sbx-btn:hover{color:var(--sbx-accent)}
.sbx-btn:focus-visible{outline:2px solid var(--sbx-accent);outline-offset:2px;border-radius:999px}
.sbx-face{position:relative;display:flex;align-items:center;justify-content:center;width:100%;height:100%;border-radius:999px;transition:transform .18s var(--sbx-soft)}
.sbx-btn:active .sbx-face{transform:scale(.88)}
.sbx-ic{position:absolute;inset:0;margin:auto;width:1.15em;height:1.15em;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transition:transform .42s var(--sbx-ease),opacity .2s ease}
.sbx-mono{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:.92em;font-weight:700;letter-spacing:-.02em;font-family:ui-sans-serif,system-ui,sans-serif}
.sbx-trigger .sbx-ic-close{opacity:0;transform:rotate(-90deg) scale(.4)}
.sbx-trigger[aria-expanded="true"] .sbx-ic-share{opacity:0;transform:rotate(90deg) scale(.4)}
.sbx-trigger[aria-expanded="true"] .sbx-ic-close{opacity:1;transform:none}
.sbx-trigger[aria-expanded="true"]{color:var(--sbx-strong)}
.sbx-clip{overflow:hidden;width:0;padding:.25em 0;margin:-.25em 0;transition:width .56s var(--sbx-ease)}
.sbx-clip[data-open="true"]{width:auto}
.sbx-track{position:relative;display:flex;align-items:center;width:max-content;padding:0 .125em 0 0;gap:.125em}
.sbx-lens{position:absolute;top:0;left:0;height:100%;border-radius:999px;pointer-events:none;opacity:0;background:color-mix(in srgb,var(--sbx-accent) 15%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--sbx-accent) 28%,transparent);transition:transform .34s var(--sbx-ease),width .34s var(--sbx-soft),opacity .2s ease}
.sbx-item{opacity:0;transform:translateX(-.6em) scale(.5);filter:blur(4px);transition:transform .22s ease calc((var(--n) - var(--i)) * 28ms),opacity .16s ease calc((var(--n) - var(--i)) * 28ms),filter .16s ease calc((var(--n) - var(--i)) * 28ms),color .2s ease}
.sbx-clip[data-open="true"] .sbx-item{opacity:1;transform:none;filter:none;transition:transform .5s var(--sbx-ease) calc(var(--i) * 48ms + 60ms),opacity .24s ease calc(var(--i) * 48ms + 60ms),filter .3s ease calc(var(--i) * 48ms + 60ms),color .2s ease}
.sbx-copy .sbx-ic-check{opacity:0;transform:scale(.4)}
.sbx-copy .sbx-ic-check path{stroke-dasharray:1;stroke-dashoffset:1;transition:stroke-dashoffset .4s ease .08s}
.sbx-copy[data-state="ok"]{color:var(--sbx-accent)}
.sbx-copy[data-state="ok"] .sbx-ic-link{opacity:0;transform:scale(.4)}
.sbx-copy[data-state="ok"] .sbx-ic-check{opacity:1;transform:none}
.sbx-copy[data-state="ok"] .sbx-ic-check path{stroke-dashoffset:0}
.sbx-cap{position:absolute;left:50%;top:100%;margin-top:.75em;transform:translate(-50%,-.3em);display:inline-flex;align-items:center;gap:.5em;padding:.3em .8em;border-radius:999px;font-size:.75em;line-height:1.3;white-space:nowrap;pointer-events:none;opacity:0;color:var(--sbx-fg);transition:opacity .18s ease,transform .3s var(--sbx-soft)}
.sbx-cap[data-on="true"]{opacity:1;transform:translate(-50%,0)}
.sbx-cap[data-kind="ok"]{color:var(--sbx-strong)}
.sbx-cap[data-kind="err"]{color:#f87171}
.sbx-dot{width:.5em;height:.5em;border-radius:999px;background:var(--sbx-accent)}
.sbx-cap[data-kind="err"] .sbx-dot{background:#f87171}
.sbx-cap[data-kind="hint"] .sbx-dot{display:none}
@media (prefers-reduced-motion:reduce){
.sbx-clip,.sbx-lens,.sbx-pill::before,.sbx-cap,.sbx-face{transition:none}
.sbx-item,.sbx-clip[data-open="true"] .sbx-item{transform:none;filter:none;transition:opacity .15s ease}
.sbx-ic,.sbx-copy .sbx-ic-check path{transition:opacity .15s ease}
.sbx-trigger .sbx-ic-close,.sbx-trigger[aria-expanded="true"] .sbx-ic-share,.sbx-copy[data-state="ok"] .sbx-ic-link,.sbx-copy .sbx-ic-check{transform:none}
.sbx-cap,.sbx-cap[data-on="true"]{transform:translate(-50%,0)}
}
`;

const noopSubscribe = () => () => {};
const getHref = () => window.location.href;
const getServerHref = () => "";
const canNativeShare = () => typeof navigator !== "undefined" && typeof navigator.share === "function";
const noNativeShare = () => false;

async function copyText(value: string): Promise<boolean> {
  if (!value) return false;
  try {
    if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
      await navigator.clipboard.writeText(value);
      return true;
    }
  } catch {
    /* fall through to the legacy path */
  }
  try {
    const prev = document.activeElement as HTMLElement | null;
    const ta = document.createElement("textarea");
    ta.value = value;
    ta.setAttribute("readonly", "");
    ta.style.cssText = "position:fixed;top:0;left:0;opacity:0;pointer-events:none";
    document.body.appendChild(ta);
    ta.select();
    ta.setSelectionRange(0, value.length);
    const ok = document.execCommand("copy");
    ta.remove();
    prev?.focus?.({ preventScroll: true });
    return ok;
  } catch {
    return false;
  }
}

function Icon({ name }: { name: "share" | "close" | "mail" | "link" | "check" | "native" }) {
  const cls = "sbx-ic sbx-ic-" + (name === "native" ? "nat" : name);
  const common = { className: cls, viewBox: "0 0 24 24", "aria-hidden": true as const, focusable: false as const };
  if (name === "share")
    return (
      <svg {...common}>
        <circle cx="18" cy="5.5" r="2.6" />
        <circle cx="6" cy="12" r="2.6" />
        <circle cx="18" cy="18.5" r="2.6" />
        <path d="M8.3 10.8l7.4-4.1M8.3 13.2l7.4 4.1" />
      </svg>
    );
  if (name === "close")
    return (
      <svg {...common}>
        <path d="M6.5 6.5l11 11M17.5 6.5l-11 11" />
      </svg>
    );
  if (name === "mail")
    return (
      <svg {...common}>
        <rect x="3" y="5.5" width="18" height="13" rx="3" />
        <path d="M4 8l8 5.6L20 8" />
      </svg>
    );
  if (name === "link")
    return (
      <svg {...common}>
        <path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1" />
        <path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" />
      </svg>
    );
  if (name === "check")
    return (
      <svg {...common}>
        <path d="M5.5 12.5l4.2 4.2L18.5 7.8" pathLength={1} />
      </svg>
    );
  return (
    <svg {...common}>
      <path d="M12 15V4M8 8l4-4 4 4M5 13v5a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-5" />
    </svg>
  );
}

type Item = {
  key: ShareTarget | "native";
  label: string;
  hint: string;
  href?: string;
  external?: boolean;
};

export function ShareButtonExpand({
  url,
  title = "",
  text = "",
  targets = DEFAULT_TARGETS,
  native = true,
  defaultOpen = false,
  tone = "auto",
  accent = "#ef4444",
  onOpenChange,
  onShare,
  className = "",
}: ShareButtonExpandProps) {
  const [open, setOpenState] = useState(defaultOpen);
  const [hint, setHint] = useState<string | null>(null);
  const [status, setStatus] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
  const rootRef = useRef<HTMLDivElement>(null);
  const pillRef = useRef<HTMLDivElement>(null);
  const clipRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);
  const lensRef = useRef<HTMLSpanElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const lensShown = useRef(false);
  const mounted = useRef(false);
  const timer = useRef(0);

  const pageHref = useSyncExternalStore(noopSubscribe, getHref, getServerHref);
  const nativeOk = useSyncExternalStore(noopSubscribe, canNativeShare, noNativeShare);
  const shareUrl = url ?? pageHref;
  const safeAccent = ACCENT_RE.test(accent.trim()) ? accent.trim() : "#ef4444";

  const enc = encodeURIComponent;
  const post = text || title;
  const all: Record<ShareTarget, Item> = {
    x: {
      key: "x",
      label: "Share on X",
      hint: "Post on X",
      href: "https://x.com/intent/post?" + (post ? "text=" + enc(post) + "&" : "") + "url=" + enc(shareUrl),
      external: true,
    },
    linkedin: {
      key: "linkedin",
      label: "Share on LinkedIn",
      hint: "Share on LinkedIn",
      href: "https://www.linkedin.com/sharing/share-offsite/?url=" + enc(shareUrl),
      external: true,
    },
    email: {
      key: "email",
      label: "Share via email",
      hint: "Send by email",
      href: "mailto:?subject=" + enc(title || "Worth a look") + "&body=" + enc((text ? text + "\n\n" : "") + shareUrl),
    },
    copy: { key: "copy", label: "Copy link", hint: "Copy link" },
  };
  const items: Item[] = [];
  for (const k of targets) {
    const it = all[k];
    if (it && !items.some((x) => x.key === k)) items.push(it);
  }
  if (native && nativeOk) items.push({ key: "native", label: "More share options", hint: "More options" });
  const count = items.length;
  const copied = status?.kind === "ok";

  function setOpen(next: boolean) {
    setOpenState(next);
    if (!next) {
      setHint(null);
      hideLens();
    }
    onOpenChange?.(next);
  }

  function flash(next: { kind: "ok" | "err"; text: string }) {
    window.clearTimeout(timer.current);
    setStatus(next);
    timer.current = window.setTimeout(() => setStatus(null), 1900);
  }

  function moveLens(el: HTMLElement) {
    const lens = lensRef.current;
    if (!lens) return;
    if (!lensShown.current) {
      lens.style.transition = "opacity .2s ease";
      lens.style.transform = "translateX(" + el.offsetLeft + "px)";
      lens.style.width = el.offsetWidth + "px";
      void lens.offsetWidth;
      lens.style.transition = "";
      lensShown.current = true;
    } else {
      lens.style.transform = "translateX(" + el.offsetLeft + "px)";
      lens.style.width = el.offsetWidth + "px";
    }
    lens.style.opacity = "1";
  }

  function hideLens() {
    const lens = lensRef.current;
    if (lens) lens.style.opacity = "0";
    lensShown.current = false;
  }

  function activate(el: HTMLElement, label: string) {
    moveLens(el);
    setHint(label);
  }

  function leaveTrack(e: React.FocusEvent | React.PointerEvent) {
    const next = (e as React.FocusEvent).relatedTarget;
    if (next instanceof Node && trackRef.current?.contains(next)) return;
    if (trackRef.current?.contains(document.activeElement) && e.type === "pointerleave") return;
    hideLens();
    setHint(null);
  }

  async function activateItem(it: Item) {
    if (it.key === "copy") {
      const ok = await copyText(shareUrl);
      if (ok) {
        flash({ kind: "ok", text: "Link copied" });
        onShare?.("copy");
      } else {
        flash({ kind: "err", text: "Couldn’t copy" });
      }
      return;
    }
    if (it.key === "native") {
      try {
        await navigator.share({ title: title || undefined, text: text || undefined, url: shareUrl || undefined });
        onShare?.("native");
        setOpen(false);
      } catch {
        /* dismissed the sheet */
      }
      return;
    }
    onShare?.(it.key);
    setOpen(false);
  }

  function onPillMove(e: React.PointerEvent<HTMLDivElement>) {
    const pill = pillRef.current;
    if (!pill) return;
    const r = pill.getBoundingClientRect();
    if (!r.width) return;
    pill.style.setProperty("--sbx-mx", ((e.clientX - r.left) * (pill.offsetWidth / r.width)).toFixed(1) + "px");
    pill.style.setProperty("--sbx-my", ((e.clientY - r.top) * (pill.offsetHeight / r.height)).toFixed(1) + "px");
  }

  function onKeyDown(e: React.KeyboardEvent) {
    if (e.key === "Escape" && open) {
      e.preventDefault();
      setOpen(false);
      triggerRef.current?.focus();
      return;
    }
    const nav = ["ArrowRight", "ArrowLeft", "Home", "End"];
    if (!nav.includes(e.key) || !rootRef.current) return;
    const els = Array.from(rootRef.current.querySelectorAll<HTMLElement>("[data-sbx-item]")).filter(
      (el) => !el.closest("[inert]"),
    );
    const at = els.indexOf(document.activeElement as HTMLElement);
    if (at < 0) return;
    e.preventDefault();
    let to = at;
    if (e.key === "ArrowRight") to = Math.min(els.length - 1, at + 1);
    else if (e.key === "ArrowLeft") to = Math.max(0, at - 1);
    else if (e.key === "Home") to = 0;
    else to = els.length - 1;
    els[to]?.focus();
  }

  // The clip animates between 0 and the measured track width (layout pixels).
  useEffect(() => {
    const clip = clipRef.current;
    const track = trackRef.current;
    if (!clip || !track) return;
    clip.toggleAttribute("inert", !open);
    const fit = () => {
      clip.style.width = open ? track.offsetWidth + "px" : "0px";
    };
    if (!mounted.current) {
      clip.style.transition = "none";
      fit();
      void clip.offsetWidth;
      clip.style.transition = "";
      mounted.current = true;
    } else {
      fit();
    }
    if (!open || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => {
      clip.style.width = track.offsetWidth + "px";
    });
    ro.observe(track);
    return () => ro.disconnect();
  }, [open, count]);

  const onOpenChangeRef = useRef(onOpenChange);
  const hideLensRef = useRef(hideLens);
  useEffect(() => {
    onOpenChangeRef.current = onOpenChange;
    hideLensRef.current = hideLens;
  });

  // Close on a press outside while open.
  useEffect(() => {
    if (!open) return;
    const down = (e: PointerEvent) => {
      if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
        setOpenState(false);
        setHint(null);
        hideLensRef.current();
        onOpenChangeRef.current?.(false);
      }
    };
    document.addEventListener("pointerdown", down);
    return () => document.removeEventListener("pointerdown", down);
  }, [open]);

  useEffect(() => () => window.clearTimeout(timer.current), []);

  const capText = status ? status.text : hint;
  const capKind = status ? status.kind : "hint";

  return (
    <>
      <style>{CSS}</style>
      <div
        ref={rootRef}
        onKeyDown={onKeyDown}
        className={"sbx-root sbx-tone-" + tone + " relative inline-flex align-middle " + className}
        style={{ ["--sbx-accent" as string]: safeAccent }}
      >
        <div ref={pillRef} className="sbx-pill" onPointerMove={onPillMove}>
          <button
            ref={triggerRef}
            type="button"
            data-sbx-item=""
            className="sbx-btn sbx-trigger"
            aria-label={open ? "Close share options" : "Share"}
            aria-expanded={open}
            aria-controls="sbx-targets"
            onClick={() => setOpen(!open)}
          >
            <span className="sbx-face">
              <Icon name="share" />
              <Icon name="close" />
            </span>
          </button>
          <div ref={clipRef} className="sbx-clip" data-open={open} aria-hidden={!open}>
            <div
              ref={trackRef}
              id="sbx-targets"
              role="group"
              aria-label="Share options"
              className="sbx-track"
              onPointerLeave={leaveTrack}
              onBlur={leaveTrack}
            >
              <span ref={lensRef} className="sbx-lens" aria-hidden />
              {items.map((it, i) => {
                const style = { ["--i" as string]: i, ["--n" as string]: count - 1 };
                const handlers = {
                  "data-sbx-item": "",
                  onPointerEnter: (e: React.PointerEvent<HTMLElement>) => activate(e.currentTarget, it.hint),
                  onFocus: (e: React.FocusEvent<HTMLElement>) => activate(e.currentTarget, it.hint),
                  style,
                };
                const face =
                  it.key === "x" ? (
                    <span className="sbx-mono" aria-hidden>
                      X
                    </span>
                  ) : it.key === "linkedin" ? (
                    <span className="sbx-mono" aria-hidden>
                      in
                    </span>
                  ) : it.key === "email" ? (
                    <Icon name="mail" />
                  ) : it.key === "copy" ? (
                    <>
                      <Icon name="link" />
                      <Icon name="check" />
                    </>
                  ) : (
                    <Icon name="native" />
                  );
                if (it.href) {
                  return (
                    <a
                      key={it.key}
                      href={it.href}
                      {...(it.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
                      aria-label={it.label + (it.external ? " (opens in a new tab)" : "")}
                      className="sbx-btn sbx-item"
                      onClick={() => void activateItem(it)}
                      {...handlers}
                    >
                      <span className="sbx-face">{face}</span>
                    </a>
                  );
                }
                return (
                  <button
                    key={it.key}
                    type="button"
                    aria-label={it.key === "copy" && copied ? "Link copied" : it.label}
                    className={"sbx-btn sbx-item" + (it.key === "copy" ? " sbx-copy" : "")}
                    data-state={it.key === "copy" && copied ? "ok" : undefined}
                    onClick={() => void activateItem(it)}
                    {...handlers}
                  >
                    <span className="sbx-face">{face}</span>
                  </button>
                );
              })}
            </div>
          </div>
        </div>
        <div className="sbx-cap" role="status" aria-live="polite" data-on={!!capText} data-kind={capKind}>
          <span className="sbx-dot" aria-hidden />
          {capText}
        </div>
      </div>
    </>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

A round share button in a thin pill. Press it and the pill widens on a spring (about 560ms, it overshoots a few pixels and settles) while the targets slide in one after another, each from a little to the left, small and blurred, 48ms apart. Closing runs the same thing backwards, faster. A lens slides between options as you hover or tab across them, a soft light follows the pointer along the pill, and a caption underneath names the option you are on. X, LinkedIn and email are real links built with encodeURIComponent and opened with rel=“noopener noreferrer”; their marks are plain monograms, not brand logos. Copy link writes the address to the clipboard, falls back to a hidden textarea where the Clipboard API is missing, and only turns into a check and says “Link copied” when the write actually succeeded; if it fails the caption says so and nothing pretends. Where the browser has navigator.share a “More options” button joins the row. Use the targets prop to pick and order the options, tone to inherit the page colour (auto), or paint a light or dark surface, accent to recolour hover and focus, and onShare and onOpenChange to hook in analytics.

Curator’s note

Share Button Expand, rebuilt in October. The first version leaned on Framer Motion and lucide; this one is plain React and CSS, adds LinkedIn and the native share sheet, and copies for real.

Pairs well with