Share Sheet Present
The panel rises over a dimmed page and its destinations arrive in a quick wave.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState, type CSSProperties } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Share Sheet Present
*
* Two arrivals in one: the panel, and the destinations on it. The panel
* rises over a dimming scrim and settles once; the destinations sweep
* in just behind it on a stagger short enough to be felt rather than
* counted, which is what makes the grid read as contents rather than
* as decoration.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The panel sits above a scrim, so it uses the CSS system colors
* `Canvas`/`CanvasText` and lands opaque in a light app and in a dark one.
* Works with zero props; tune via `variant`, `title`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ShareSheetPresentProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Panel heading, also its accessible name. */
title?: string;
/** Notified whenever the panel opens or closes. */
onOpenChange?: (open: boolean) => void;
};
type VariantConfig = {
spring: { type: "spring"; stiffness: number; damping: number };
/** Gap between consecutive destinations arriving. */
stagger: number;
/** px each destination travels. */
rise: number;
scrimSeconds: number;
};
// Damping ratios (damping / 2√stiffness) stay at or above 0.8. A panel
// is the largest moving surface in this library and overshoot at that
// size reads as a mistake, not as personality.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Lands flat, wave almost simultaneous. For a share used constantly.
subtle: {
spring: { type: "spring", stiffness: 460, damping: 44 },
stagger: 0.02,
rise: 6,
scrimSeconds: 0.18,
},
// One settle, a readable wave. All-purpose.
default: {
spring: { type: "spring", stiffness: 360, damping: 34 },
stagger: 0.035,
rise: 12,
scrimSeconds: 0.22,
},
// Slower rise, wider wave, for a panel that is the moment.
playful: {
spring: { type: "spring", stiffness: 300, damping: 29 },
stagger: 0.05,
rise: 18,
scrimSeconds: 0.26,
},
};
const ACCENT = "#5B5BD6";
const EASE = [0.32, 0.72, 0, 1] as const;
/** Destinations: two people, then the generic targets. All inline SVG —
* a share row that depends on an emoji font breaks on the platform
* that lacks the glyph. */
const CONTACTS = [
{ id: "ao", label: "Amara", initials: "AO", tint: "#E08A3C" },
{ id: "tl", label: "Theo", initials: "TL", tint: "#5B8DEF" },
];
const TARGETS = [
{
id: "message",
label: "Message",
tint: "#3FA98B",
glyph: (
<path
d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v7a2.5 2.5 0 0 1-2.5 2.5H10l-4.4 3.4a.6.6 0 0 1-1-.5V16h-.1A2.5 2.5 0 0 1 4 13.5Z"
fill="currentColor"
/>
),
},
{
id: "mail",
label: "Mail",
tint: "#4A7DF0",
glyph: (
<>
<rect x="3" y="5.5" width="18" height="13" rx="2.6" fill="currentColor" />
<path
d="m5.6 8.6 5.5 4a1.5 1.5 0 0 0 1.8 0l5.5-4"
stroke="#ffffff"
strokeWidth="1.6"
strokeLinecap="round"
fill="none"
/>
</>
),
},
{
id: "link",
label: "Copy link",
tint: "#8A8A99",
glyph: (
<>
<path
d="M10.2 13.8a3.4 3.4 0 0 0 5 .3l2.4-2.4a3.4 3.4 0 0 0-4.8-4.8l-1.2 1.2"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
fill="none"
/>
<path
d="M13.8 10.2a3.4 3.4 0 0 0-5-.3l-2.4 2.4a3.4 3.4 0 0 0 4.8 4.8l1.2-1.2"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
fill="none"
/>
</>
),
},
{
id: "more",
label: "More",
tint: "#8A8A99",
glyph: (
<>
<circle cx="6.5" cy="12" r="1.7" fill="currentColor" />
<circle cx="12" cy="12" r="1.7" fill="currentColor" />
<circle cx="17.5" cy="12" r="1.7" fill="currentColor" />
</>
),
},
];
const ACTIONS = ["Add to a collection", "Mute this account", "Report post"];
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function ShareSheetPresent({
variant = "default",
title = "Share",
onOpenChange,
}: ShareSheetPresentProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [open, setOpen] = useState(false);
const setPanel = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setOpen(false);
onOpenChange?.(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
// Reduced motion: the panel still arrives over a dimmed page and the
// destinations still appear — a full-height rise and a wave are
// exactly the movement this setting asks us to drop.
const panelMotion = reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, transition: { duration: 0.12 } },
transition: { duration: 0.16, ease: "easeOut" as const },
}
: {
initial: { y: "100%" },
animate: { y: "0%" },
exit: {
y: "100%",
transition: { duration: 0.22, ease: "easeIn" as const },
},
transition: cfg.spring,
};
const waveVariants = {
hidden: {},
shown: {
transition: {
staggerChildren: reduceMotion ? 0 : cfg.stagger,
delayChildren: reduceMotion ? 0 : 0.06,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: reduceMotion ? 0 : cfg.rise },
shown: {
opacity: 1,
y: 0,
transition: { duration: reduceMotion ? 0.14 : 0.28, ease: EASE },
},
};
return (
<div
style={{
position: "relative",
display: "flex",
flexDirection: "column",
width: 300,
height: 390,
borderRadius: 26,
border: `1px solid ${tone(12)}`,
background: tone(5),
color: "inherit",
// The panel is positioned against this box rather than the
// viewport, so the pattern drops into a preview or an embedded
// card. Swap `absolute` for `fixed` to present over a whole app.
overflow: "hidden",
fontSize: 13.5,
}}
>
<div style={{ flex: 1, padding: "20px 18px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 32,
height: 32,
borderRadius: "50%",
background: "#D2557A",
color: "#ffffff",
fontSize: 12,
fontWeight: 650,
}}
>
RE
</span>
<div style={{ lineHeight: 1.3 }}>
<div style={{ fontWeight: 600 }}>Rowan Ellis</div>
<div style={{ fontSize: 11.5, opacity: 0.5 }}>@rowan.builds · 3h</div>
</div>
</div>
<p style={{ margin: "12px 0 0", lineHeight: 1.5 }}>
Three screens became one, and the slowest step got twice as fast.
Notes on how, in the thread.
</p>
</div>
<div style={{ padding: "0 18px 20px" }}>
<button
type="button"
onClick={() => setPanel(true)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: "100%",
padding: "12px 14px",
borderRadius: 12,
border: 0,
background: ACCENT,
color: "#ffffff",
fontFamily: "inherit",
fontSize: 13.5,
fontWeight: 600,
cursor: "pointer",
}}
>
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden>
<path
d="M10 13.4V3.6M10 3.6 6.3 7.3M10 3.6l3.7 3.7M4.4 12.2v3.1a1.5 1.5 0 0 0 1.5 1.5h8.2a1.5 1.5 0 0 0 1.5-1.5v-3.1"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{title}
</button>
</div>
{/* Two siblings rather than a fragment: AnimatePresence only
tracks its direct children. */}
<AnimatePresence>
{open && (
<motion.div
key="scrim"
aria-hidden
onClick={() => setPanel(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: cfg.scrimSeconds, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
// A scrim darkens in both themes — light or dark, the page
// behind a panel recedes — so this one stays literal.
background: "rgba(0,0,0,0.5)",
cursor: "pointer",
}}
/>
)}
{open && (
<motion.div
key="panel"
role="dialog"
aria-modal="true"
aria-label={title}
{...panelMotion}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
padding: "10px 14px 16px",
borderRadius: "22px 22px 0 0",
// Above the scrim, so it cannot be translucent.
// `Canvas`/`CanvasText` are the CSS system colors for page
// background and page text: the panel lands light in a
// light app and dark in a dark one, legible either way.
background: "Canvas",
color: "CanvasText",
borderTop: `1px solid ${tone(14)}`,
boxShadow: "0 -14px 34px rgba(0,0,0,0.3)",
}}
>
<div
aria-hidden
style={{
width: 36,
height: 4,
margin: "0 auto 12px",
borderRadius: 2,
background: tone(26),
}}
/>
<motion.div
variants={waveVariants}
initial="hidden"
animate="shown"
style={{
display: "flex",
gap: 6,
overflowX: "auto",
paddingBottom: 4,
}}
>
{CONTACTS.map((contact) => (
<motion.button
key={contact.id}
type="button"
variants={itemVariants}
style={destinationStyle}
>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 46,
height: 46,
borderRadius: "50%",
background: contact.tint,
color: "#ffffff",
fontSize: 15,
fontWeight: 650,
}}
>
{contact.initials}
</span>
<span style={labelStyle}>{contact.label}</span>
</motion.button>
))}
{TARGETS.map((target) => (
<motion.button
key={target.id}
type="button"
variants={itemVariants}
style={destinationStyle}
>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 46,
height: 46,
borderRadius: "50%",
background: tone(9),
border: `1px solid ${tone(12)}`,
color: target.tint,
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
{target.glyph}
</svg>
</span>
<span style={labelStyle}>{target.label}</span>
</motion.button>
))}
</motion.div>
<motion.ul
variants={waveVariants}
initial="hidden"
animate="shown"
style={{
margin: "12px 0 0",
padding: 0,
listStyle: "none",
borderTop: `1px solid ${tone(10)}`,
}}
>
{ACTIONS.map((action) => (
<motion.li key={action} variants={itemVariants}>
<button
type="button"
style={{
width: "100%",
padding: "10px 6px",
border: 0,
background: "none",
color: "inherit",
fontFamily: "inherit",
fontSize: 13,
textAlign: "left",
cursor: "pointer",
}}
>
{action}
</button>
</motion.li>
))}
</motion.ul>
<button
type="button"
onClick={() => setPanel(false)}
style={{
width: "100%",
marginTop: 8,
padding: "11px 14px",
borderRadius: 11,
border: `1px solid ${tone(16)}`,
background: tone(7),
color: "inherit",
fontFamily: "inherit",
fontSize: 13.5,
fontWeight: 600,
cursor: "pointer",
}}
>
Cancel
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
const destinationStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 6,
flexShrink: 0,
width: 64,
padding: "4px 0",
border: 0,
background: "none",
color: "inherit",
fontFamily: "inherit",
cursor: "pointer",
};
const labelStyle: CSSProperties = {
fontSize: 11,
opacity: 0.65,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 62,
};About this pattern
A share panel is two arrivals in one: the surface, and the grid of destinations on it. If both land together the row reads as a static image; if the wave is too slow the panel feels like it is still loading. The compromise is a stagger short enough to be felt rather than counted — the destinations sweep left to right just behind the panel's own settle, which is what makes the grid read as contents rather than as decoration. Destination glyphs are inline SVG, so nothing depends on a platform emoji font.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Modal sheet
Contacts first, destination apps second, secondary actions listed below.
Related patterns
- Swipe to ReplyDragging a message uncovers a reply mark that grows with the pull, snaps once at the threshold, and springs back.
- Bookmark SaveThe mark fills from the notch up, tucks into its corner, and a ribbon slides down behind the card edge.
- Follow Button SwapFollow becomes Following in place — the button never changes width.