Web InnoventixFreeCode

Share Menu

Original · free

share menu with options

byWeb InnoventixReact + Tailwind
menusharemenus
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/menu-share.json
menu-share.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/* ------------------------------------------------------------------ */
/* Shareable resource (real, specific copy)                            */
/* ------------------------------------------------------------------ */

const SHARE_URL = "https://frontstack.dev/guides/menu-button-pattern";
const SHARE_TITLE = "The menu button pattern, done right";
const SHARE_TEXT =
  "Roving tabindex, focus return, typeahead and the keyboard model that makes a dropdown feel native.";

/* ------------------------------------------------------------------ */
/* Icons (inline SVG only)                                             */
/* ------------------------------------------------------------------ */

function ShareIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className="h-[18px] w-[18px]"
    >
      <circle cx="18" cy="5" r="2.6" />
      <circle cx="6" cy="12" r="2.6" />
      <circle cx="18" cy="19" r="2.6" />
      <path d="m8.3 10.7 7.4-4.3M8.3 13.3l7.4 4.3" />
    </svg>
  );
}

function LinkIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className="h-[18px] w-[18px]"
    >
      <path d="M9 13a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 0 0-5-5l-1 1" />
      <path d="M15 11a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5l1-1" />
    </svg>
  );
}

function CopyIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className="h-[18px] w-[18px]"
    >
      <rect x="9" y="9" width="11" height="11" rx="2.4" />
      <path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.1}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className="h-[18px] w-[18px]"
    >
      <path d="m5 12.5 4.5 4.5L19 7" />
    </svg>
  );
}

function ChevronIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.9}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className="h-4 w-4"
    >
      <path d="m6 9 6 6 6-6" />
    </svg>
  );
}

function DeviceShareIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className="h-[18px] w-[18px]"
    >
      <path d="M12 3v11" />
      <path d="m8.5 6.5 3.5-3.5 3.5 3.5" />
      <path d="M6 12H5a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-5a2 2 0 0 0-2-2h-1" />
    </svg>
  );
}

function XIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
      className="h-[16px] w-[16px]"
    >
      <path d="M13.9 10.6 21.1 2h-2.06l-6.02 7.02L8.2 2H2l7.56 11-7.56 9h2.06l6.35-7.4L15.8 22H22l-8.1-11.4Zm-2.24 2.6-.74-1.05L4.9 3.6h2.9l4.72 6.75.74 1.05 6.14 8.78h-2.9l-4.9-7.03Z" />
    </svg>
  );
}

function LinkedInIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
      className="h-[17px] w-[17px]"
    >
      <path d="M4.98 3.5A2.5 2.5 0 1 1 5 8.5a2.5 2.5 0 0 1-.02-5ZM3.2 9.25h3.56V21H3.2V9.25Zm5.86 0h3.41v1.6h.05c.48-.9 1.63-1.85 3.36-1.85 3.6 0 4.26 2.37 4.26 5.45V21h-3.55v-5.2c0-1.24-.02-2.84-1.73-2.84-1.74 0-2 1.35-2 2.75V21H9.06V9.25Z" />
    </svg>
  );
}

function FacebookIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
      className="h-[17px] w-[17px]"
    >
      <path d="M22 12.06C22 6.5 17.52 2 12 2S2 6.5 2 12.06c0 5 3.66 9.15 8.44 9.94v-7.03H7.9v-2.9h2.54V9.85c0-2.51 1.49-3.9 3.77-3.9 1.1 0 2.24.2 2.24.2v2.47h-1.26c-1.24 0-1.63.78-1.63 1.57v1.87h2.78l-.44 2.9h-2.34V22c4.78-.79 8.44-4.94 8.44-9.94Z" />
    </svg>
  );
}

function EmailIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className="h-[18px] w-[18px]"
    >
      <rect x="3" y="5" width="18" height="14" rx="2.4" />
      <path d="m4 7 8 6 8-6" />
    </svg>
  );
}

function ExternalIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className="h-3.5 w-3.5"
    >
      <path d="M8 16 16 8" />
      <path d="M9 8h7v7" />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* Clipboard helper (with legacy fallback)                             */
/* ------------------------------------------------------------------ */

async function copyText(text: string): Promise<boolean> {
  try {
    if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
      await navigator.clipboard.writeText(text);
      return true;
    }
  } catch {
    /* fall through to legacy path */
  }
  try {
    const ta = document.createElement("textarea");
    ta.value = text;
    ta.setAttribute("readonly", "");
    ta.style.position = "absolute";
    ta.style.left = "-9999px";
    document.body.appendChild(ta);
    ta.select();
    const ok = document.execCommand("copy");
    document.body.removeChild(ta);
    return ok;
  } catch {
    return false;
  }
}

/* ------------------------------------------------------------------ */
/* Types                                                               */
/* ------------------------------------------------------------------ */

type ShareItem = {
  id: string;
  label: string;
  hint: string;
  icon: ReactNode;
  accent: string;
  href?: string;
  onSelect?: () => void;
  closeOnSelect: boolean;
  trailing?: ReactNode;
};

/* ------------------------------------------------------------------ */
/* Component                                                           */
/* ------------------------------------------------------------------ */

export default function ShareMenu() {
  const uid = useId();
  const triggerId = `${uid}-trigger`;
  const menuId = `${uid}-menu`;
  const headingId = `${uid}-heading`;
  const linkFieldId = `${uid}-linkfield`;

  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const [copied, setCopied] = useState(false);
  const [rowCopied, setRowCopied] = useState(false);
  const [canNativeShare, setCanNativeShare] = useState(false);
  const [announce, setAnnounce] = useState("");

  const triggerRef = useRef<HTMLButtonElement | null>(null);
  const popoverRef = useRef<HTMLDivElement | null>(null);
  const itemRefs = useRef<Array<HTMLAnchorElement | HTMLButtonElement | null>>(
    [],
  );
  const pendingFocus = useRef<"first" | "last">("first");
  const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const rowTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  const prefersReduced = useReducedMotion();
  const reduce = prefersReduced ?? false;

  /* --- feature detection (client only) --- */
  useEffect(() => {
    if (typeof navigator !== "undefined" && typeof navigator.share === "function") {
      setCanNativeShare(true);
    }
  }, []);

  useEffect(() => {
    return () => {
      if (copyTimer.current) clearTimeout(copyTimer.current);
      if (rowTimer.current) clearTimeout(rowTimer.current);
    };
  }, []);

  /* --- actions --- */
  const copyLink = useCallback(async () => {
    const ok = await copyText(SHARE_URL);
    if (!ok) {
      setAnnounce("Could not copy the link. Select it and copy manually.");
      return;
    }
    setCopied(true);
    setAnnounce("Link copied to clipboard.");
    if (copyTimer.current) clearTimeout(copyTimer.current);
    copyTimer.current = setTimeout(() => setCopied(false), 2200);
  }, []);

  const copyLinkRow = useCallback(async () => {
    const ok = await copyText(SHARE_URL);
    if (!ok) {
      setAnnounce("Could not copy the link. Select it and copy manually.");
      return;
    }
    setRowCopied(true);
    setAnnounce("Link copied to clipboard.");
    if (rowTimer.current) clearTimeout(rowTimer.current);
    rowTimer.current = setTimeout(() => setRowCopied(false), 2200);
  }, []);

  const nativeShare = useCallback(async () => {
    try {
      if (typeof navigator !== "undefined" && typeof navigator.share === "function") {
        await navigator.share({
          title: SHARE_TITLE,
          text: SHARE_TEXT,
          url: SHARE_URL,
        });
      }
    } catch {
      /* user dismissed the share sheet */
    }
  }, []);

  /* --- menu open/close --- */
  const closeMenu = useCallback((returnFocus: boolean) => {
    setOpen(false);
    if (returnFocus) {
      requestAnimationFrame(() => triggerRef.current?.focus());
    }
  }, []);

  const openMenu = useCallback((focus: "first" | "last") => {
    pendingFocus.current = focus;
    setOpen(true);
  }, []);

  /* --- items --- */
  const enc = encodeURIComponent;
  const items = useMemo<ShareItem[]>(() => {
    const list: ShareItem[] = [];

    if (canNativeShare) {
      list.push({
        id: "native",
        label: "Share via this device",
        hint: "Open the system share sheet",
        icon: <DeviceShareIcon />,
        accent:
          "text-violet-600 dark:text-violet-300 bg-violet-500/10 ring-violet-500/20",
        onSelect: nativeShare,
        closeOnSelect: true,
      });
    }

    list.push({
      id: "copy",
      label: "Copy link",
      hint: copied ? "Copied to clipboard" : "Grab a plain URL",
      icon: copied ? <CheckIcon /> : <LinkIcon />,
      accent: copied
        ? "text-emerald-600 dark:text-emerald-300 bg-emerald-500/10 ring-emerald-500/20"
        : "text-sky-600 dark:text-sky-300 bg-sky-500/10 ring-sky-500/20",
      onSelect: copyLink,
      closeOnSelect: false,
      trailing: copied ? (
        <span className="sharemenu-badge inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[11px] font-semibold text-emerald-700 dark:text-emerald-300">
          Copied
        </span>
      ) : undefined,
    });

    list.push({
      id: "x",
      label: "Post on X",
      hint: "Share to your followers",
      icon: <XIcon />,
      accent:
        "text-zinc-900 dark:text-zinc-100 bg-zinc-900/[0.06] dark:bg-white/10 ring-zinc-900/10 dark:ring-white/15",
      href: `https://twitter.com/intent/tweet?url=${enc(SHARE_URL)}&text=${enc(SHARE_TEXT)}`,
      closeOnSelect: true,
      trailing: <ExternalIcon />,
    });

    list.push({
      id: "linkedin",
      label: "Share on LinkedIn",
      hint: "Post to your network",
      icon: <LinkedInIcon />,
      accent:
        "text-sky-700 dark:text-sky-300 bg-sky-500/10 ring-sky-500/20",
      href: `https://www.linkedin.com/sharing/share-offsite/?url=${enc(SHARE_URL)}`,
      closeOnSelect: true,
      trailing: <ExternalIcon />,
    });

    list.push({
      id: "facebook",
      label: "Share on Facebook",
      hint: "Post to your timeline",
      icon: <FacebookIcon />,
      accent:
        "text-indigo-600 dark:text-indigo-300 bg-indigo-500/10 ring-indigo-500/20",
      href: `https://www.facebook.com/sharer/sharer.php?u=${enc(SHARE_URL)}`,
      closeOnSelect: true,
      trailing: <ExternalIcon />,
    });

    list.push({
      id: "email",
      label: "Email a colleague",
      hint: "Open your mail client",
      icon: <EmailIcon />,
      accent:
        "text-amber-600 dark:text-amber-300 bg-amber-500/10 ring-amber-500/20",
      href: `mailto:?subject=${enc(SHARE_TITLE)}&body=${enc(`${SHARE_TEXT}\n\n${SHARE_URL}`)}`,
      closeOnSelect: true,
      trailing: <ExternalIcon />,
    });

    return list;
  }, [canNativeShare, copied, copyLink, nativeShare, enc]);

  /* --- focus management on open --- */
  useEffect(() => {
    if (!open) return;
    const idx = pendingFocus.current === "last" ? items.length - 1 : 0;
    setActiveIndex(idx);
    const raf = requestAnimationFrame(() => {
      itemRefs.current[idx]?.focus();
    });
    return () => cancelAnimationFrame(raf);
  }, [open, items.length]);

  /* --- dismiss on outside pointer --- */
  useEffect(() => {
    if (!open) return;
    function onPointerDown(event: PointerEvent) {
      const target = event.target as Node | null;
      if (!target) return;
      if (
        popoverRef.current?.contains(target) ||
        triggerRef.current?.contains(target)
      ) {
        return;
      }
      setOpen(false);
    }
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [open]);

  /* --- roving focus helpers --- */
  const focusIndex = useCallback((index: number) => {
    setActiveIndex(index);
    itemRefs.current[index]?.focus();
  }, []);

  const typeahead = useCallback(
    (char: string): number => {
      const lower = char.toLowerCase();
      const count = items.length;
      for (let step = 1; step <= count; step += 1) {
        const i = (activeIndex + step) % count;
        if (items[i].label.toLowerCase().startsWith(lower)) return i;
      }
      return -1;
    },
    [activeIndex, items],
  );

  const onMenuKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLDivElement>) => {
      const count = items.length;
      switch (event.key) {
        case "ArrowDown":
          event.preventDefault();
          focusIndex((activeIndex + 1) % count);
          break;
        case "ArrowUp":
          event.preventDefault();
          focusIndex((activeIndex - 1 + count) % count);
          break;
        case "Home":
          event.preventDefault();
          focusIndex(0);
          break;
        case "End":
          event.preventDefault();
          focusIndex(count - 1);
          break;
        case "Escape":
          event.preventDefault();
          closeMenu(true);
          break;
        case "Tab":
          event.preventDefault();
          closeMenu(true);
          break;
        default:
          if (event.key.length === 1 && /\S/.test(event.key)) {
            const found = typeahead(event.key);
            if (found >= 0) {
              event.preventDefault();
              focusIndex(found);
            }
          }
      }
    },
    [activeIndex, items.length, focusIndex, closeMenu, typeahead],
  );

  const onTriggerKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLButtonElement>) => {
      if (event.key === "ArrowDown") {
        event.preventDefault();
        openMenu("first");
      } else if (event.key === "ArrowUp") {
        event.preventDefault();
        openMenu("last");
      }
    },
    [openMenu],
  );

  const onItemSelect = useCallback(
    (item: ShareItem) => {
      item.onSelect?.();
      if (item.closeOnSelect) closeMenu(true);
    },
    [closeMenu],
  );

  const setItemRef =
    (index: number) => (el: HTMLAnchorElement | HTMLButtonElement | null) => {
      itemRefs.current[index] = el;
    };

  const itemClass =
    "group/item flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left outline-none transition-colors " +
    "text-zinc-700 hover:bg-zinc-100 focus-visible:bg-zinc-100 " +
    "dark:text-zinc-200 dark:hover:bg-white/[0.06] dark:focus-visible:bg-white/[0.06] " +
    "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 " +
    "focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 motion-reduce:transition-none";

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-4 py-16 sm:py-24 dark:from-zinc-950 dark:to-zinc-900">
      <style>{`
        @keyframes sharemenu-badge-in {
          0%   { opacity: 0; transform: scale(0.6); }
          60%  { transform: scale(1.08); }
          100% { opacity: 1; transform: scale(1); }
        }
        .sharemenu-badge { animation: sharemenu-badge-in 260ms cubic-bezier(0.16, 1, 0.3, 1); }
        @keyframes sharemenu-dot {
          0%, 100% { opacity: 0.35; }
          50%      { opacity: 1; }
        }
        .sharemenu-live-dot { animation: sharemenu-dot 1.6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .sharemenu-badge, .sharemenu-live-dot { animation: none; }
        }
      `}</style>

      {/* soft backdrop glow */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute left-1/2 top-0 h-72 w-[36rem] -translate-x-1/2 rounded-full bg-indigo-400/10 blur-3xl dark:bg-indigo-500/10"
      />

      <div className="relative mx-auto w-full max-w-2xl">
        <p className="mb-3 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
          Frontstack · Field guide
        </p>

        <article className="rounded-3xl border border-zinc-200 bg-white p-6 shadow-[0_1px_0_0_rgba(0,0,0,0.02),0_24px_48px_-24px_rgba(24,24,27,0.18)] sm:p-8 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-[0_24px_48px_-24px_rgba(0,0,0,0.6)]">
          <div className="flex items-center gap-2 text-xs font-medium text-zinc-500 dark:text-zinc-400">
            <span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 font-semibold text-emerald-700 dark:text-emerald-300">
              Accessibility
            </span>
            <span aria-hidden="true">·</span>
            <span>7 min read</span>
          </div>

          <h2 className="mt-4 text-balance text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl dark:text-zinc-50">
            {SHARE_TITLE}
          </h2>
          <p className="mt-3 text-pretty text-[15px] leading-relaxed text-zinc-600 dark:text-zinc-300">
            A practical breakdown of the WAI-ARIA menu button: roving tabindex,
            focus return to the trigger, first-character typeahead, and the exact
            keyboard model users expect from a native control.
          </p>

          <div className="mt-6 flex items-center gap-3">
            <span
              aria-hidden="true"
              className="grid h-10 w-10 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-500 text-sm font-semibold text-white"
            >
              MO
            </span>
            <div className="text-sm">
              <p className="font-medium text-zinc-900 dark:text-zinc-100">
                Marisa Okafor
              </p>
              <p className="text-zinc-500 dark:text-zinc-400">
                Updated Jul 15, 2026
              </p>
            </div>
          </div>

          <div className="my-6 h-px w-full bg-zinc-200 dark:bg-zinc-800" />

          {/* --- action row with the share menu --- */}
          <div className="flex flex-wrap items-center justify-between gap-3">
            <p className="text-sm text-zinc-500 dark:text-zinc-400">
              Send this to a teammate.
            </p>

            <div className="relative">
              <button
                ref={triggerRef}
                id={triggerId}
                type="button"
                aria-haspopup="menu"
                aria-expanded={open}
                aria-controls={open ? menuId : undefined}
                onClick={() => (open ? closeMenu(false) : openMenu("first"))}
                onKeyDown={onTriggerKeyDown}
                className="inline-flex items-center gap-2 rounded-xl bg-zinc-900 px-4 py-2.5 text-sm font-semibold text-white shadow-sm outline-none transition-colors hover:bg-zinc-800 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white motion-reduce:transition-none dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-white dark:focus-visible:ring-offset-zinc-900"
              >
                <ShareIcon />
                Share
                <span
                  className={`transition-transform duration-200 motion-reduce:transition-none ${
                    open ? "rotate-180" : "rotate-0"
                  }`}
                >
                  <ChevronIcon />
                </span>
              </button>

              <AnimatePresence>
                {open && (
                  <motion.div
                    ref={popoverRef}
                    initial={{
                      opacity: 0,
                      y: reduce ? 0 : -6,
                      scale: reduce ? 1 : 0.96,
                    }}
                    animate={{ opacity: 1, y: 0, scale: 1 }}
                    exit={{
                      opacity: 0,
                      y: reduce ? 0 : -6,
                      scale: reduce ? 1 : 0.97,
                    }}
                    transition={{
                      duration: reduce ? 0 : 0.16,
                      ease: [0.16, 1, 0.3, 1],
                    }}
                    style={{ transformOrigin: "top right" }}
                    className="absolute right-0 top-[calc(100%+0.5rem)] z-30 w-72 origin-top-right rounded-2xl border border-zinc-200 bg-white p-1.5 shadow-[0_24px_48px_-16px_rgba(24,24,27,0.25)] dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-[0_24px_48px_-16px_rgba(0,0,0,0.7)]"
                  >
                    <p
                      id={headingId}
                      className="px-2.5 pb-1.5 pt-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500"
                    >
                      Share this article
                    </p>

                    <div
                      id={menuId}
                      role="menu"
                      aria-labelledby={headingId}
                      aria-orientation="vertical"
                      onKeyDown={onMenuKeyDown}
                      className="flex flex-col gap-0.5"
                    >
                      {items.map((item, index) => {
                        const inner = (
                          <>
                            <span
                              aria-hidden="true"
                              className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ring-1 ${item.accent}`}
                            >
                              {item.icon}
                            </span>
                            <span className="min-w-0 flex-1">
                              <span className="block truncate text-sm font-medium">
                                {item.label}
                              </span>
                              <span className="block truncate text-xs text-zinc-400 dark:text-zinc-500">
                                {item.hint}
                              </span>
                            </span>
                            {item.trailing ? (
                              <span className="shrink-0 text-zinc-400 dark:text-zinc-500">
                                {item.trailing}
                              </span>
                            ) : null}
                          </>
                        );

                        if (item.href) {
                          return (
                            <a
                              key={item.id}
                              ref={setItemRef(index)}
                              href={item.href}
                              target="_blank"
                              rel="noopener noreferrer"
                              role="menuitem"
                              tabIndex={index === activeIndex ? 0 : -1}
                              onFocus={() => setActiveIndex(index)}
                              onMouseEnter={() => setActiveIndex(index)}
                              onClick={() => onItemSelect(item)}
                              className={itemClass}
                            >
                              {inner}
                            </a>
                          );
                        }

                        return (
                          <button
                            key={item.id}
                            ref={setItemRef(index)}
                            type="button"
                            role="menuitem"
                            tabIndex={index === activeIndex ? 0 : -1}
                            onFocus={() => setActiveIndex(index)}
                            onMouseEnter={() => setActiveIndex(index)}
                            onClick={() => onItemSelect(item)}
                            className={itemClass}
                          >
                            {inner}
                          </button>
                        );
                      })}
                    </div>

                    <p className="flex items-center gap-1.5 px-2.5 pb-1.5 pt-2 text-[11px] text-zinc-400 dark:text-zinc-500">
                      <span
                        aria-hidden="true"
                        className="sharemenu-live-dot inline-block h-1.5 w-1.5 rounded-full bg-emerald-500"
                      />
                      Anyone with the link can read this article.
                    </p>
                  </motion.div>
                )}
              </AnimatePresence>
            </div>
          </div>

          {/* --- always-visible quick copy row --- */}
          <div className="mt-5">
            <label
              htmlFor={linkFieldId}
              className="mb-1.5 block text-xs font-medium text-zinc-500 dark:text-zinc-400"
            >
              Article link
            </label>
            <div className="flex items-stretch gap-2">
              <div className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-700 dark:bg-zinc-800/60">
                <span className="text-zinc-400 dark:text-zinc-500">
                  <LinkIcon />
                </span>
                <input
                  id={linkFieldId}
                  type="text"
                  readOnly
                  value={SHARE_URL}
                  onFocus={(event) => event.currentTarget.select()}
                  className="w-full min-w-0 truncate bg-transparent text-sm text-zinc-700 outline-none dark:text-zinc-200"
                />
              </div>
              <button
                type="button"
                onClick={copyLinkRow}
                aria-label={rowCopied ? "Link copied" : "Copy article link"}
                className={`inline-flex shrink-0 items-center gap-1.5 rounded-xl px-3.5 py-2 text-sm font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white motion-reduce:transition-none dark:focus-visible:ring-offset-zinc-900 ${
                  rowCopied
                    ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
                    : "border border-zinc-200 text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-white/[0.06]"
                }`}
              >
                {rowCopied ? <CheckIcon /> : <CopyIcon />}
                <span className={rowCopied ? "sharemenu-badge" : undefined}>
                  {rowCopied ? "Copied" : "Copy"}
                </span>
              </button>
            </div>
          </div>
        </article>
      </div>

      {/* polite announcements for assistive tech */}
      <div aria-live="polite" className="sr-only">
        {announce}
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →