Button
Inline Action
A reversing progress bar and a shine inside a shrinking action pill
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
inline-action.tsx
import React, { useEffect, useId, useRef, useState } from "react"
import {
Pressable,
Platform,
StyleSheet,
Text,
View,
useColorScheme,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
Easing,
ReduceMotion,
cancelAnimation,
interpolateColor,
useAnimatedProps,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSpring,
withTiming,
} from "react-native-reanimated"
import Svg, {
ClipPath,
Defs,
FeGaussianBlur,
Filter,
G,
LinearGradient,
Path,
Rect,
Stop,
Text as SvgText,
} from "react-native-svg"
type Status = "idle" | "loading" | "success"
export type InlineActionProps = {
label?: string
icon?: React.ReactNode
actionText?: string
onAction: () => Promise<void>
onError?: (error: unknown) => void
theme?: "light" | "dark" | "system"
width?: number
radius?: number
fontSize?: number
actionFontSize?: number
backgroundColor?: string
borderColor?: string
foregroundColor?: string
surfaceColor?: string
progressTrackColor?: string
progressColor?: string
successColor?: string
checkColor?: string
style?: StyleProp<ViewStyle>
testID?: string
}
const AnimatedGroup = Animated.createAnimatedComponent(G)
const AnimatedRect = Animated.createAnimatedComponent(Rect)
const IS_WEB = Platform.OS === "web"
class BlurTarget extends FeGaussianBlur {
setNativeProps = (
props: React.ComponentProps<typeof FeGaussianBlur> & {
style?: React.ComponentProps<typeof FeGaussianBlur>
}
) => {
if (IS_WEB) {
const amount = props.style?.stdDeviation ?? props.stdDeviation
const element = (
this as unknown as {
elementRef?: {
current: {
setAttribute: (name: string, value: string) => void
} | null
}
}
).elementRef?.current
if (amount !== undefined)
element?.setAttribute("stdDeviation", String(amount))
} else {
this.root?.setNativeProps(props)
}
}
}
const AnimatedBlur = Animated.createAnimatedComponent(BlurTarget)
const AnimatedText = Animated.createAnimatedComponent(SvgText)
const SPRING = {
stiffness: 400,
damping: 35,
mass: 1,
reduceMotion: ReduceMotion.System,
} as const
const COLOR_EASE = Easing.bezier(0.4, 0, 0.2, 1)
export function InlineAction({
label = "Calendar",
icon,
actionText = "Sync Events",
onAction,
onError,
theme = "system",
width,
radius = 999,
fontSize,
actionFontSize,
backgroundColor,
borderColor,
foregroundColor,
surfaceColor,
progressTrackColor,
progressColor,
successColor,
checkColor,
style,
testID,
}: InlineActionProps) {
const system = useColorScheme()
const dark = theme === "dark" || (theme === "system" && system === "dark")
const { fontScale, width: viewportWidth } = useWindowDimensions()
const large = viewportWidth >= 640
const cardWidth = width ?? (viewportWidth >= 768 ? 384 : 320)
const labelSize = fontSize ?? (large ? 18 : 15)
const actionSize = actionFontSize ?? (large ? 15 : 13)
const iconSize = large ? 52 : 44
const [status, setStatus] = useState<Status>("idle")
const phase = useRef<Status>("idle")
const mounted = useRef(true)
const filterId = `inline-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`
const idle = useSharedValue(1)
const loading = useSharedValue(0)
const success = useSharedValue(0)
const actionWidth = useSharedValue(120)
const travel = useSharedValue(0)
const shine = useSharedValue(0)
const colorTheme = useSharedValue(dark ? 1 : 0)
const detailTheme = useSharedValue(dark ? 1 : 0)
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, [])
useEffect(() => {
colorTheme.set(
withTiming(dark ? 1 : 0, { duration: 300, easing: COLOR_EASE })
)
detailTheme.set(
withTiming(dark ? 1 : 0, { duration: 150, easing: COLOR_EASE })
)
}, [dark, colorTheme, detailTheme])
useEffect(() => {
idle.set(withSpring(status === "idle" ? 1 : 0, SPRING))
loading.set(withSpring(status === "loading" ? 1 : 0, SPRING))
success.set(withSpring(status === "success" ? 1 : 0, SPRING))
actionWidth.set(withSpring(status === "success" ? 48 : 120, SPRING))
if (status === "loading") {
travel.set(0)
travel.set(
withRepeat(
withTiming(1, {
duration: 800,
easing: Easing.bezier(0.42, 0, 0.58, 1),
reduceMotion: ReduceMotion.System,
}),
-1,
true
)
)
}
if (status === "success") {
shine.set(0)
shine.set(
withDelay(
100,
withTiming(1, {
duration: 700,
easing: Easing.bezier(0, 0, 0.58, 1),
reduceMotion: ReduceMotion.System,
})
)
)
const timer = setTimeout(() => {
phase.current = "idle"
setStatus("idle")
}, 2000)
return () => clearTimeout(timer)
}
return () => {
cancelAnimation(travel)
}
}, [status, idle, loading, success, actionWidth, travel, shine])
const trigger = async () => {
if (phase.current !== "idle") return
phase.current = "loading"
setStatus("loading")
try {
await onAction()
if (!mounted.current) return
phase.current = "success"
setStatus("success")
} catch (error) {
if (!mounted.current) return
phase.current = "idle"
setStatus("idle")
onError?.(error)
}
}
const cardStyle = useAnimatedStyle(() => ({
backgroundColor:
backgroundColor ??
interpolateColor(colorTheme.get(), [0, 1], ["#ffffff", "#18181b"]),
borderColor:
borderColor ??
interpolateColor(colorTheme.get(), [0, 1], ["#f0f0f0", "#27272a"]),
}))
const surfaceStyle = useAnimatedStyle(() => ({
backgroundColor:
surfaceColor ??
interpolateColor(detailTheme.get(), [0, 1], ["#f0f0f0", "#27272a"]),
}))
const labelStyle = useAnimatedStyle(() => ({
color:
foregroundColor ??
interpolateColor(detailTheme.get(), [0, 1], ["#000000", "#ffffff"]),
}))
const actionStyle = useAnimatedStyle(() => ({ width: actionWidth.get() }))
const idleProps = useAnimatedProps(() => ({
opacity: Math.max(0, Math.min(1, idle.get())),
}))
const loadingProps = useAnimatedProps(() => ({
opacity: Math.max(0, Math.min(1, loading.get())),
}))
const successProps = useAnimatedProps(() => ({
opacity: Math.max(0, Math.min(1, success.get())),
}))
const idleBlur = useAnimatedProps(() => {
const amount = Math.max(0, 4 * (1 - idle.get()))
return {
stdDeviation: amount,
stdDeviationX: amount,
stdDeviationY: amount,
}
})
const loadingBlur = useAnimatedProps(() => {
const amount = Math.max(0, 4 * (1 - loading.get()))
return {
stdDeviation: amount,
stdDeviationX: amount,
stdDeviationY: amount,
}
})
const successBlur = useAnimatedProps(() => {
const amount = Math.max(0, 4 * (1 - success.get()))
return {
stdDeviation: amount,
stdDeviationX: amount,
stdDeviationY: amount,
}
})
const labelProps = useAnimatedProps(() => ({
fill:
foregroundColor ??
interpolateColor(detailTheme.get(), [0, 1], ["#000000", "#ffffff"]),
}))
const trackProps = useAnimatedProps(() => ({
width: Math.max(0, actionWidth.get() - 16),
}))
const barProps = useAnimatedProps(() => ({
x: 8 + Math.max(0, actionWidth.get() - 16) * 0.7 * travel.get(),
width: Math.max(0, actionWidth.get() - 16) * 0.3,
}))
const successRect = useAnimatedProps(() => ({
width: Math.max(0, actionWidth.get() - 16),
}))
const checkPosition = useAnimatedProps<{
transform?: string
matrix?: number[]
}>(() =>
IS_WEB
? { transform: `translate(${(actionWidth.get() - 24) / 2} 12)` }
: { matrix: [1, 0, 0, 1, (actionWidth.get() - 24) / 2, 12] }
)
const shineProps = useAnimatedProps<{
transform?: string
matrix?: number[]
}>(() => {
const skew = Math.tan((-40 * Math.PI) / 180)
const x = shine.get() * actionWidth.get() - skew * 24
return IS_WEB
? { transform: `matrix(1 0 ${skew} 1 ${x} 0)` }
: { matrix: [1, 0, skew, 1, x, 0] }
})
return (
<View style={[styles.wrapper, style]} testID={testID}>
<Animated.View
style={[
styles.card,
{ width: cardWidth, borderRadius: radius },
cardStyle,
]}
>
<View style={[styles.labelRow, { gap: large ? 12 : 8 }]}>
<Animated.View
style={[
styles.icon,
{ width: iconSize, height: iconSize, borderRadius: iconSize / 2 },
surfaceStyle,
]}
>
<View style={{ transform: [{ scale: large ? 1 : 0.9 }] }}>
{icon ?? (
<Svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
stroke={dark ? "#f4f4f5" : "#1f1f1f"}
strokeWidth={2}
>
<Rect x={3} y={4} width={18} height={18} rx={2} />
<Path d="M16 2v4M8 2v4M3 10h18" />
</Svg>
)}
</View>
</Animated.View>
<Animated.Text
numberOfLines={1}
style={[styles.label, { fontSize: labelSize }, labelStyle]}
>
{label}
</Animated.Text>
</View>
<Animated.View style={[styles.actionSlot, actionStyle]}>
<Animated.View style={[styles.action, surfaceStyle]}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={trigger}
disabled={status !== "idle"}
accessibilityRole="button"
accessibilityLabel={
status === "success" ? `${actionText} complete` : actionText
}
accessibilityState={{
disabled: status !== "idle",
busy: status === "loading",
}}
accessibilityLiveRegion="polite"
testID={testID ? `${testID}-action` : undefined}
>
<Svg width="100%" height={48} accessible={false}>
<Defs>
<Filter
id={`${filterId}-idle`}
x="-50%"
y="-100%"
width="200%"
height="300%"
>
<AnimatedBlur animatedProps={idleBlur} />
</Filter>
<Filter
id={`${filterId}-loading`}
x="-50%"
y="-100%"
width="200%"
height="300%"
>
<AnimatedBlur animatedProps={loadingBlur} />
</Filter>
<Filter
id={`${filterId}-success`}
x="-50%"
y="-100%"
width="200%"
height="300%"
>
<AnimatedBlur animatedProps={successBlur} />
</Filter>
<LinearGradient
id={`${filterId}-shine`}
x1="0%"
y1="0%"
x2="100%"
y2="0%"
>
<Stop
offset="0"
stopColor={dark ? "#000000" : "#ffffff"}
stopOpacity={0}
/>
<Stop
offset="0.5"
stopColor={dark ? "#000000" : "#ffffff"}
stopOpacity={dark ? 0.2 : 0.5}
/>
<Stop
offset="1"
stopColor={dark ? "#000000" : "#ffffff"}
stopOpacity={0}
/>
</LinearGradient>
<ClipPath id={`${filterId}-clip`}>
<AnimatedRect
x={8}
y={8}
height={32}
rx={16}
animatedProps={successRect}
/>
</ClipPath>
</Defs>
<AnimatedGroup
animatedProps={idleProps}
filter={`url(#${filterId}-idle)`}
>
<AnimatedText
x="50%"
y={24 + actionSize * fontScale * 0.35}
textAnchor="middle"
fontSize={actionSize * fontScale}
fontWeight="700"
animatedProps={labelProps}
>
{actionText}
</AnimatedText>
</AnimatedGroup>
<AnimatedGroup
animatedProps={loadingProps}
filter={`url(#${filterId}-loading)`}
>
<AnimatedRect
x={8}
y={21}
height={6}
rx={3}
fill={progressTrackColor ?? (dark ? "#52525b" : "#d4d4d8")}
animatedProps={trackProps}
/>
<AnimatedRect
y={21}
height={6}
rx={3}
fill={progressColor ?? (dark ? "#d4d4d8" : "#212121")}
animatedProps={barProps}
/>
</AnimatedGroup>
<AnimatedGroup
animatedProps={successProps}
filter={`url(#${filterId}-success)`}
>
<AnimatedRect
x={8}
y={8}
height={32}
rx={16}
fill={successColor ?? (dark ? "#ffffff" : "#050505")}
animatedProps={successRect}
/>
<AnimatedGroup animatedProps={checkPosition}>
<Path
d="m20 6-11 11-5-5"
stroke={checkColor ?? (dark ? "#000000" : "#ffffff")}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</AnimatedGroup>
<G clipPath={`url(#${filterId}-clip)`}>
<AnimatedGroup animatedProps={shineProps}>
<AnimatedRect
x={8}
y={8}
height={32}
fill={`url(#${filterId}-shine)`}
animatedProps={successRect}
/>
</AnimatedGroup>
</G>
</AnimatedGroup>
</Svg>
</Pressable>
</Animated.View>
</Animated.View>
</Animated.View>
<Text style={styles.announcement} accessibilityLiveRegion="polite">
{status === "success"
? `${actionText} complete`
: status === "loading"
? `${actionText} in progress`
: ""}
</Text>
</View>
)
}
const styles = StyleSheet.create({
wrapper: { width: "100%", alignItems: "center", paddingHorizontal: 16 },
card: {
maxWidth: "100%",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: 12,
borderWidth: 1.5,
overflow: "hidden",
boxShadow: "0px 1px 2px rgba(0,0,0,0.05)",
},
labelRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
flex: 1,
minWidth: 0,
marginRight: 8,
},
icon: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: "center",
justifyContent: "center",
},
label: { fontWeight: "700", flexShrink: 1 },
actionSlot: { width: 120, height: 48 },
action: {
position: "absolute",
right: 0,
width: "100%",
height: 48,
borderRadius: 24,
overflow: "hidden",
},
announcement: {
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
opacity: 0,
},
})
Give the gestures and motion a run on a real Android phone.