Skip to content
pixelfor Expo
Ctrl K

Interaction

Share Sheet

An expressive interaction rebuilt for touch

Try it on your phone

Share Sheet

Scan to open Share Sheet in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Watermelon UI

Our Expo component

React Native Web

Component source

Make it yours

One file to copy into your app.

share-sheet.tsx

import React, { useEffect, useRef, useState } from "react"
import {
  Image,
  Platform,
  Pressable,
  Text,
  View,
  useColorScheme,
  type StyleProp,
  type ViewStyle,
} from "react-native"
import Animated, {
  Easing,
  cancelAnimation,
  useAnimatedProps,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import Svg, { Circle, Path, Polyline } from "react-native-svg"
export interface ShareUser {
  id: string
  name: string
  avatar: string
}
export interface ShareSheetProps {
  users: ShareUser[]
  onShareComplete?: (user: ShareUser) => void
  theme?: "light" | "dark" | "system"
  colors?: Partial<{
    button: string
    foreground: string
    panel: string
    text: string
    highlight: string
    border: string
  }>
  panelWidth?: number
  buttonSize?: number
  buttonRadius?: number
  panelRadius?: number
  fontFamily?: string
  style?: StyleProp<ViewStyle>
  testID?: string
}
const PANEL_SPRING = { stiffness: 240, damping: 20, mass: 1 }
const SEND_SPRING = { stiffness: 400, damping: 25, mass: 1 }
const CHECK_SPRING = { stiffness: 400, damping: 20, mass: 1 }
const ROW_SPRING = { stiffness: 500, damping: 25, mass: 1 }
const CIRCUMFERENCE = 44 * Math.PI
const AnimatedCircle = Animated.createAnimatedComponent(Circle)
function ContactRow({
  user,
  index,
  width,
  active,
  choose,
  focus,
  colors,
  fontFamily,
}: {
  user: ShareUser
  index: number
  width: number
  active: boolean
  choose: (user: ShareUser) => void
  focus: (index: number | null) => void
  colors: { text: string }
  fontFamily?: string
}) {
  const hover = useSharedValue(0)
  const rowX = useSharedValue(0)
  const paddingOffset = useSharedValue(0)
  const reduced = useReducedMotion()
  useEffect(() => {
    hover.set(withSpring(active ? 1 : 0, PANEL_SPRING))
    rowX.set(withSpring(active ? -10 : 0, ROW_SPRING))
    paddingOffset.set(
      withTiming(active ? -8 : 0, {
        duration: 450,
        easing: Easing.bezier(0.4, 0, 0.1, 1),
      })
    )
  }, [active, hover, rowX, paddingOffset])
  const row = useAnimatedStyle(() => ({
    transform: [{ translateX: reduced ? 0 : rowX.get() }],
  }))
  const avatar = useAnimatedStyle(() => ({
    borderRadius: 28 - 16 * hover.get(),
    transform: [
      { translateX: reduced ? (active ? -8 : 0) : paddingOffset.get() },
    ],
  }))
  const label = useAnimatedStyle(() => ({
    transform: [
      { translateX: reduced ? (active ? -8 : 0) : paddingOffset.get() },
    ],
  }))
  return (
    <Animated.View style={[{ width: width - 24, height: 60 }, row]}>
      <Pressable
        accessibilityRole="button"
        accessibilityLabel={`Share with ${user.name}`}
        onHoverIn={() => focus(index)}
        onHoverOut={() => focus(null)}
        onFocus={() => focus(index)}
        onBlur={() => focus(null)}
        onPressIn={() => focus(index)}
        onPressOut={() => focus(null)}
        onPress={() => choose(user)}
        style={{
          height: 60,
          padding: 8,
          flexDirection: "row",
          alignItems: "center",
          gap: 12,
        }}
      >
        <Animated.View
          style={[{ width: 44, height: 44, overflow: "hidden" }, avatar]}
        >
          <Image
            source={{ uri: user.avatar }}
            style={{ width: 44, height: 44 }}
          />
        </Animated.View>
        <Animated.View style={label}>
          <Text
            numberOfLines={1}
            style={{
              color: colors.text,
              fontSize: 17,
              fontWeight: "500",
              letterSpacing: -0.425,
              fontFamily,
              maxWidth: width - 100,
            }}
          >
            {user.name}
          </Text>
        </Animated.View>
      </Pressable>
    </Animated.View>
  )
}
export function ShareSheet({
  users,
  onShareComplete,
  theme = "system",
  colors: overrides,
  panelWidth = 340,
  buttonSize = 56,
  buttonRadius = 17,
  panelRadius = 38,
  fontFamily = Platform.OS === "web" ? "Arial" : undefined,
  style,
  testID,
}: ShareSheetProps) {
  const scheme = useColorScheme()
  const dark = theme === "dark" || (theme === "system" && scheme === "dark")
  const colors = {
    button: dark ? "#f5f5f5" : "#171717",
    foreground: dark ? "#171717" : "#fafafa",
    panel: dark ? "#171717" : "#f5f5f5",
    text: dark ? "#e5e5e5" : "#262626",
    highlight: dark ? "#262626" : "#ffffff",
    border: dark ? "#262626" : "#e5e5e5",
    ...overrides,
  }
  const [status, setStatus] = useState<"idle" | "open" | "sending" | "success">(
    "idle"
  )
  const statusRef = useRef(status)
  const [selected, setSelected] = useState<ShareUser | null>(null)
  const [highlighted, setHighlighted] = useState<number | null>(null)
  const timers = useRef<ReturnType<typeof setTimeout>[]>([])
  const callback = useRef(onShareComplete)
  callback.current = onShareComplete
  const reduced = useReducedMotion()
  const entrance = useSharedValue(0)
  const panel = useSharedValue(0)
  const icon = useSharedValue(1)
  const iconY = useSharedValue(0)
  const sending = useSharedValue(0)
  const sendingY = useSharedValue(40)
  const avatarScale = useSharedValue(1)
  const avatarOpacity = useSharedValue(1)
  const progress = useSharedValue(0)
  const check = useSharedValue(0)
  const highlightY = useSharedValue(0)
  const highlightOpacity = useSharedValue(0)
  const lastHighlight = useRef<number | null>(null)
  const panelHeight = 40 + 60 * users.length
  useEffect(() => {
    entrance.set(withSpring(1, PANEL_SPRING))
    return () => {
      timers.current.forEach(clearTimeout)
      ;[
        entrance,
        panel,
        icon,
        iconY,
        sending,
        sendingY,
        avatarScale,
        avatarOpacity,
        progress,
        check,
        highlightY,
        highlightOpacity,
      ].forEach(cancelAnimation)
    }
  }, [])
  useEffect(() => {
    highlightOpacity.set(highlighted === null ? 0 : 1)
    if (highlighted !== null) {
      if (lastHighlight.current === null || reduced)
        highlightY.set(60 * highlighted)
      else highlightY.set(withSpring(60 * highlighted, PANEL_SPRING))
    }
    lastHighlight.current = highlighted
  }, [highlighted])
  function change(next: typeof status) {
    statusRef.current = next
    setStatus(next)
  }
  function open() {
    if (statusRef.current !== "idle") return
    change("open")
    icon.set(
      withTiming(0, {
        duration: 300,
        easing: Easing.bezier(0.25, 0.1, 0.35, 1),
      })
    )
    iconY.set(withSpring(-40, ROW_SPRING))
    panel.set(withSpring(1, PANEL_SPRING))
  }
  function choose(user: ShareUser) {
    if (statusRef.current !== "open") return
    setSelected(user)
    avatarScale.set(1)
    avatarOpacity.set(1)
    change("sending")
    panel.set(withSpring(0, PANEL_SPRING))
    sendingY.set(reduced ? 0 : 40)
    sendingY.set(withSpring(0, SEND_SPRING))
    sending.set(0)
    sending.set(withSpring(1, SEND_SPRING))
    progress.set(0)
    progress.set(
      withTiming(1, { duration: 1800, easing: Easing.bezier(0.42, 0, 0.58, 1) })
    )
    check.set(0)
    timers.current.push(
      setTimeout(() => {
        change("success")
        check.set(withSpring(1, CHECK_SPRING))
        timers.current.push(
          setTimeout(() => {
            change("idle")
            avatarScale.set(
              withTiming(reduced ? 1 : 0.6, {
                duration: 300,
                easing: Easing.bezier(0.42, 0, 0.58, 1),
              })
            )
            avatarOpacity.set(
              withTiming(0, {
                duration: 300,
                easing: Easing.bezier(0.42, 0, 0.58, 1),
              })
            )
            check.set(withSpring(0, CHECK_SPRING))
            sendingY.set(withSpring(reduced ? 0 : -40, SEND_SPRING))
            sending.set(withSpring(0, SEND_SPRING))
            iconY.set(reduced ? 0 : 40)
            iconY.set(withSpring(0, ROW_SPRING))
            icon.set(
              withTiming(1, {
                duration: 300,
                easing: Easing.bezier(0.25, 0.1, 0.35, 1),
              })
            )
            setHighlighted(null)
            callback.current?.(user)
          }, 800)
        )
      }, 1800)
    )
  }
  const buttonStyle = useAnimatedStyle(() => ({
    opacity: Math.max(0, Math.min(1, entrance.get())),
    transform: [{ scale: reduced ? 1 : 0.8 + 0.2 * entrance.get() }],
  }))
  const panelStyle = useAnimatedStyle(() => ({
    opacity: Math.max(0, Math.min(1, panel.get())),
    transform: [{ scale: reduced ? 1 : panel.get() }],
  }))
  const iconStyle = useAnimatedStyle(() => ({
    opacity: icon.get(),
    transform: [{ translateY: reduced ? 0 : iconY.get() }],
  }))
  const sendingStyle = useAnimatedStyle(() => ({
    opacity: Math.max(0, Math.min(1, sending.get())),
    transform: [{ translateY: reduced ? 0 : sendingY.get() }],
  }))
  const checkStyle = useAnimatedStyle(() => ({
    opacity: Math.max(0, Math.min(1, check.get())),
    transform: [{ scale: reduced ? 1 : check.get() }],
  }))
  const avatarStyle = useAnimatedStyle(() => ({
    opacity: avatarOpacity.get(),
    transform: [{ scale: avatarScale.get() }],
  }))
  const highlightStyle = useAnimatedStyle(() => ({
    opacity: highlightOpacity.get(),
    transform: [{ translateY: highlightY.get() }],
  }))
  const ringProps = useAnimatedProps(() => ({
    strokeDashoffset: CIRCUMFERENCE * (1 - progress.get()),
  }))
  return (
    <View
      testID={testID}
      style={[
        {
          width: buttonSize,
          height: buttonSize,
          alignSelf: "center",
          alignItems: "center",
          justifyContent: "center",
          overflow: "visible",
        },
        style,
      ]}
    >
      <Animated.View
        style={[
          {
            width: buttonSize,
            height: buttonSize,
            borderRadius: buttonRadius,
            backgroundColor: colors.button,
            overflow: "hidden",
            boxShadow: "0px 1px 2px rgba(0,0,0,0.05)",
          },
          buttonStyle,
        ]}
      >
        <Pressable
          accessibilityRole="button"
          accessibilityLabel={
            status === "idle"
              ? "Share"
              : status === "open"
                ? "Choose a recipient"
                : status === "sending"
                  ? "Sending"
                  : "Sent"
          }
          accessibilityState={{
            disabled: status !== "idle",
            busy: status === "sending",
          }}
          onPress={open}
          style={{
            width: buttonSize,
            height: buttonSize,
            alignItems: "center",
            justifyContent: "center",
          }}
        >
          <Animated.View style={[{ position: "absolute" }, iconStyle]}>
            <Svg
              width={26}
              height={26}
              viewBox="0 0 24 24"
              fill="none"
              stroke={colors.foreground}
              strokeWidth={2}
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <Path d="M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7M16 6l-4-4-4 4M12 2v14" />
            </Svg>
          </Animated.View>
          <Animated.View
            style={[
              {
                position: "absolute",
                width: buttonSize,
                height: buttonSize,
                alignItems: "center",
                justifyContent: "center",
              },
              sendingStyle,
            ]}
          >
            <Svg
              width={buttonSize}
              height={buttonSize}
              viewBox="0 0 56 56"
              style={{
                position: "absolute",
                transform: [{ rotate: "-90deg" }],
              }}
            >
              <Circle
                cx={28}
                cy={28}
                r={22}
                stroke={colors.foreground}
                strokeOpacity={0.15}
                strokeWidth={3}
                fill="none"
              />
              <AnimatedCircle
                cx={28}
                cy={28}
                r={22}
                stroke={colors.foreground}
                strokeWidth={3}
                fill="none"
                strokeDasharray={CIRCUMFERENCE}
                animatedProps={ringProps}
              />
            </Svg>
            {selected && (
              <Animated.View
                style={[
                  {
                    width: 40,
                    height: 40,
                    borderRadius: 20,
                    overflow: "hidden",
                  },
                  avatarStyle,
                ]}
              >
                <Image
                  source={{ uri: selected.avatar }}
                  style={{ width: 40, height: 40 }}
                />
              </Animated.View>
            )}
            <Animated.View
              style={[
                {
                  position: "absolute",
                  width: 36,
                  height: 36,
                  alignItems: "center",
                  justifyContent: "center",
                },
                checkStyle,
              ]}
            >
              <Svg width={24} height={24} viewBox="0 0 24 24">
                <Polyline
                  points="20 6 9 17 4 12"
                  fill="none"
                  stroke={colors.foreground}
                  strokeWidth={3.5}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </Svg>
            </Animated.View>
          </Animated.View>
        </Pressable>
      </Animated.View>
      <Animated.View
        aria-hidden={status !== "open"}
        accessibilityElementsHidden={status !== "open"}
        importantForAccessibility={
          status === "open" ? "auto" : "no-hide-descendants"
        }
        pointerEvents={status === "open" ? "auto" : "none"}
        style={[
          {
            position: "absolute",
            width: panelWidth,
            height: panelHeight,
            left: (buttonSize - panelWidth) / 2,
            top: (buttonSize - panelHeight) / 2,
            borderRadius: panelRadius,
            backgroundColor: colors.panel,
            paddingHorizontal: 12,
            paddingVertical: 20,
            transitionProperty: ["backgroundColor"],
            transitionDuration: 300,
            boxShadow:
              "0px 4px 6px -1px rgba(0,0,0,0.1), 0px 2px 4px -2px rgba(0,0,0,0.1)",
          },
          panelStyle,
        ]}
      >
        <Animated.View
          pointerEvents="none"
          style={[
            {
              position: "absolute",
              top: 20,
              left: -22,
              right: -2,
              height: 60,
              borderRadius: 14,
              borderWidth: 1,
              borderColor: colors.border,
              backgroundColor: colors.highlight,
              boxShadow: "0px 1px 2px rgba(0,0,0,0.05)",
            },
            highlightStyle,
          ]}
        />
        {users.map((user, index) => (
          <ContactRow
            key={user.id}
            user={user}
            index={index}
            width={panelWidth}
            active={highlighted === index}
            choose={choose}
            focus={setHighlighted}
            colors={colors}
            fontFamily={fontFamily}
          />
        ))}
      </Animated.View>
    </View>
  )
}
export default ShareSheet

Give the gestures and motion a run on a real Android phone.

Back to Watermelon UI