Share Button That Expands into Icon Row

A share button that turns into a row of five icon buttons. At rest it is an outlined button with a share icon and a label. On hover, keyboard focus or tap, the label fades and a dark strip grows from its left edge, with the icons sliding in one after another: copy link, email, message, QR code and print. Pressing an icon gives a quick flash and writes a confirmation under the buttons. The demo places it on a recipe card beside a primary button, and it opens and closes by itself until someone uses it. The card fits a 360 pixel screen.

Code

// Adapted from Kokonut UI (https://github.com/kokonut-labs/kokonutui/blob/main/components/kokonutui/social-button.tsx)
// Copyright (c) 2025 kokonutUI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";
import { Link2, Mail, MessageCircle, Printer, QrCode, Share2 } from "lucide-react";

// The actions revealed when the button opens. `done` is the confirmation shown after a press.
const actions = [
  { icon: Link2, label: "Copy link", done: "Link copied to your clipboard" },
  { icon: Mail, label: "Send by email", done: "Opening a new email" },
  { icon: MessageCircle, label: "Send in a message", done: "Opening your messages" },
  { icon: QrCode, label: "Show QR code", done: "QR code ready to scan" },
  { icon: Printer, label: "Print the recipe", done: "Sending to your printer" },
];

const SLOT = 44; // width and height of one icon button in pixels

export default function ShareButtonThatExpandsIntoIconRow() {
  const rootRef = useRef<HTMLDivElement>(null);
  const inView = useInView(rootRef);
  const reduce = useReducedMotion();
  const [open, setOpen] = useState(false);
  const [pressed, setPressed] = useState<number | null>(null);
  const [message, setMessage] = useState("");
  const [demo, setDemo] = useState(true);

  // Self-playing preview: open, close, repeat. Stops for good on the first real interaction.
  useEffect(() => {
    if (!demo || !inView || reduce) return;
    const timer = setTimeout(() => setOpen((value) => !value), open ? 2200 : 900);
    return () => clearTimeout(timer);
  }, [demo, inView, reduce, open]);

  useEffect(() => {
    if (pressed === null) return;
    const timer = setTimeout(() => setPressed(null), 300);
    return () => clearTimeout(timer);
  }, [pressed]);

  const show = (value: boolean) => {
    setDemo(false);
    setOpen(value);
  };
  const run = (index: number) => {
    setDemo(false);
    setPressed(index);
    setMessage(actions[index].done);
  };
  const ease = [0.23, 1, 0.32, 1] as const;

  return (
    <div ref={rootRef} className="flex w-full items-center justify-center bg-amber-50 px-4 py-14">
      <article className="w-full max-w-md overflow-hidden rounded-3xl border border-amber-200 bg-white shadow-xl shadow-amber-900/10">
        {/* Drawn illustration: a loaf on a board. */}
        <div className="relative h-44 bg-gradient-to-br from-amber-200 via-orange-200 to-rose-200" aria-hidden="true">
          <svg viewBox="0 0 400 176" className="absolute inset-0 h-full w-full" preserveAspectRatio="xMidYMid slice">
            <ellipse cx="200" cy="150" rx="150" ry="16" fill="#92400e" opacity="0.25" />
            <rect x="70" y="128" width="260" height="16" rx="8" fill="#b45309" />
            <path d="M110 128c-14-52 28-84 90-84s104 32 90 84z" fill="#d97706" />
            <path d="M118 124c-8-42 28-68 82-68s90 26 82 68z" fill="#f59e0b" />
            <path d="M160 70l14 40M196 62l10 48M232 68l6 42" stroke="#fde68a" strokeWidth="6" strokeLinecap="round" />
          </svg>
          <span className="absolute left-4 top-4 rounded-full bg-white/90 px-3 py-1 text-xs font-semibold text-amber-900">Weekend bake</span>
        </div>

        <div className="p-6">
          <h2 className="text-xl font-bold tracking-tight text-stone-900">Overnight country loaf</h2>
          <p className="mt-2 text-sm leading-relaxed text-stone-600">Four ingredients, twelve hours and no kneading. Mix it after dinner and bake it before breakfast.</p>
          <dl className="mt-4 flex gap-6 text-sm">
            <div>
              <dt className="text-stone-600">Hands-on</dt>
              <dd className="font-semibold text-stone-900">20 min</dd>
            </div>
            <div>
              <dt className="text-stone-600">Makes</dt>
              <dd className="font-semibold text-stone-900">1 loaf</dd>
            </div>
            <div>
              <dt className="text-stone-600">Saved by</dt>
              <dd className="font-semibold text-stone-900">2,318</dd>
            </div>
          </dl>

          <div className="mt-6 flex flex-wrap items-center gap-3">
            <a href="#" className="inline-flex h-11 items-center justify-center rounded-lg bg-amber-500 px-5 text-sm font-semibold text-stone-950 transition hover:bg-amber-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-600 focus-visible:ring-offset-2">
              Read the recipe
            </a>

            <div
              className="relative h-11"
              style={{ width: SLOT * actions.length }}
              onPointerEnter={(event) => event.pointerType === "mouse" && show(true)}
              onPointerLeave={(event) => event.pointerType === "mouse" && show(false)}
              onBlur={(event) => !event.currentTarget.contains(event.relatedTarget) && show(false)}
              onKeyDown={(event) => event.key === "Escape" && show(false)}
            >
              <motion.button
                type="button"
                aria-expanded={open}
                aria-controls="share-button-actions"
                onClick={() => show(true)}
                onFocus={() => show(true)}
                animate={{ opacity: open ? 0 : 1 }}
                transition={{ duration: reduce ? 0 : 0.2, ease: "easeInOut" }}
                className="absolute inset-y-0 left-0 inline-flex min-w-[9rem] items-center justify-center gap-2 rounded-lg border border-stone-300 bg-white px-5 text-sm font-semibold text-stone-900 transition-colors hover:bg-stone-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2"
              >
                <Share2 className="h-4 w-4" aria-hidden="true" />
                Share
              </motion.button>

              <motion.div
                id="share-button-actions"
                role="group"
                aria-label="Share options"
                className="absolute left-0 top-0 flex h-11 overflow-hidden rounded-lg"
                initial={false}
                animate={{ width: open ? SLOT * actions.length : 0 }}
                transition={{ duration: reduce ? 0 : 0.35, ease }}
              >
                {actions.map((action, i) => (
                  <motion.button
                    key={action.label}
                    type="button"
                    aria-label={action.label}
                    title={action.label}
                    tabIndex={open ? 0 : -1}
                    onClick={() => run(i)}
                    initial={false}
                    animate={{ opacity: open ? 1 : 0, x: open ? 0 : -20 }}
                    transition={{ duration: reduce ? 0 : 0.3, ease, delay: open && !reduce ? i * 0.05 : 0 }}
                    className="relative flex h-11 shrink-0 items-center justify-center overflow-hidden border-r border-white/15 bg-stone-900 text-white transition-colors last:border-r-0 hover:bg-stone-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-amber-400"
                    style={{ width: SLOT }}
                  >
                    <motion.span className="relative z-10" animate={{ scale: pressed === i ? 0.8 : 1 }} transition={{ duration: 0.15 }}>
                      <action.icon className="h-4 w-4" aria-hidden="true" />
                    </motion.span>
                    <motion.span className="absolute inset-0 bg-white" initial={false} animate={{ opacity: pressed === i ? 0.2 : 0 }} transition={{ duration: 0.2 }} aria-hidden="true" />
                  </motion.button>
                ))}
              </motion.div>
            </div>
          </div>

          <p className="mt-3 min-h-[1.25rem] text-sm text-stone-600" role="status">
            {message || "Hover, focus or tap Share to see the options."}
          </p>
        </div>
      </article>
    </div>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/share-button-that-expands-into-icon-row.json

When to use it

How to customize it

Accessibility

Questions

How does the button turn into icons?

Two layers share one spot. The label button fades to transparent while a second container, clipped with `overflow-hidden`, animates its width from zero to the full row. The icons inside slide a short distance and fade in with a small stagger.

Does it work on phones where there is no hover?

Yes. Tapping the button opens the row, and tapping anywhere else closes it because focus leaves the control. Hover handling is limited to mouse pointers so a touch does not open and close it in the same tap.

Why are there no social network logos?

The component ships with neutral actions that work for any site. To add a network, import its icon from your own icon set and add an entry to `actions`.

How do I copy the page link for real?

Inside `run`, call `navigator.clipboard.writeText(window.location.href)` for the copy action and keep the confirmation message. For phones, `navigator.share` opens the system share sheet.

Why does the wrapper have a fixed width?

It reserves room for the open row, so the layout around the button does not shift when it expands.

Adapted from Social Button in Kokonut UI, used under its open-source licence. Licences and credits

Related: Button Set: Primary, Secondary, Ghost, Icon and Loading, Floating Toolbar Expanding from a Pill with Tool Options, Button That Attracts Floating Particles.

More animated buttons or all free UI components. Start for free to have a full site built around it.

More in Animated Buttons

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap