Interaction
Share Sheet
An expressive interaction rebuilt for touch
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent 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.