Skip to content
pixelfor Expo
Ctrl K

Button

Inline Action

A reversing progress bar and a shine inside a shrinking action pill

Try it on your phone

Inline Action

Scan to open Inline Action 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.

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.

Back to Watermelon UI