Skip to content
pixelfor Expo
Ctrl K

Interaction

Quick Feedback

An expressive interaction rebuilt for touch

Try it on your phone

Quick Feedback

Scan to open Quick Feedback 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.

quick-feedback.in-progress.tsx

import React, { useEffect, useRef, useState } from "react"
import {
  Platform,
  Pressable,
  Text,
  View,
  useColorScheme,
  useWindowDimensions,
  type StyleProp,
  type ViewStyle,
} from "react-native"
import Animated, {
  cubicBezier,
  Easing,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import Svg, { Path } from "react-native-svg"
export type FeedbackStatus = "idle" | "up" | "down"
export interface QuickFeedbackProps {
  defaultStatus?: FeedbackStatus
  feedbackText?: string
  negativeText?: string
  onFeedback?: (status: "up" | "down") => void
  onUndo?: () => void
  theme?: "light" | "dark" | "system"
  compact?: boolean
  fontFamily?: string
  iconSize?: number
  radius?: number
  colors?: Partial<{
    surface: string
    text: string
    icon: string
    undo: string
    undoHover: string
    undoText: string
  }>
  style?: StyleProp<ViewStyle>
  testID?: string
}
const SPRING = { stiffness: 150, damping: 15, mass: 1 }
const BUTTON_TRANSITION = {
  duration: 150,
  easing: Easing.bezier(0.4, 0, 0.2, 1),
}
const THUMBS = {
  up: "M313.4 32.9c26 5.2 42.9 30.5 37.7 56.5l-2.3 11.4c-5.3 26.7-15.1 52.1-28.8 75.2l144 0c26.5 0 48 21.5 48 48c0 18.5-10.5 34.6-25.9 42.6C497 275.4 504 288.9 504 304c0 23.4-16.8 42.9-38.9 47.1c4.4 7.3 6.9 15.8 6.9 24.9c0 21.3-13.9 39.4-33.1 45.6c.7 3.3 1.1 6.8 1.1 10.4c0 26.5-21.5 48-48 48l-97.5 0c-19 0-37.5-5.6-53.3-16.1l-38.5-25.7C176 420.4 160 390.4 160 358.3l0-38.3 0-48 0-24.9c0-29.2 13.3-56.7 36-75l7.4-5.9c26.5-21.2 44.6-51 51.2-84.2l2.3-11.4c5.2-26 30.5-42.9 56.5-37.7zM32 192l64 0c17.7 0 32 14.3 32 32l0 224c0 17.7-14.3 32-32 32l-64 0c-17.7 0-32-14.3-32-32L0 224c0-17.7 14.3-32 32-32z",
  down: "M313.4 479.1c26-5.2 42.9-30.5 37.7-56.5l-2.3-11.4c-5.3-26.7-15.1-52.1-28.8-75.2l144 0c26.5 0 48-21.5 48-48c0-18.5-10.5-34.6-25.9-42.6C497 236.6 504 223.1 504 208c0-23.4-16.8-42.9-38.9-47.1c4.4-7.3 6.9-15.8 6.9-24.9c0-21.3-13.9-39.4-33.1-45.6c.7-3.3 1.1-6.8 1.1-10.4c0-26.5-21.5-48-48-48l-97.5 0c-19 0-37.5 5.6-53.3 16.1L202.7 73.8C176 91.6 160 121.6 160 153.7l0 38.3 0 48 0 24.9c0 29.2 13.3 56.7 36 75l7.4 5.9c26.5 21.2 44.6 51 51.2 84.2l2.3 11.4c5.2 26 30.5 42.9 56.5 37.7zM32 384l64 0c17.7 0 32-14.3 32-32l0-224c0-17.7-14.3-32-32-32L32 96C14.3 96 0 110.3 0 128L0 352c0 17.7 14.3 32 32 32z",
}
export function QuickFeedback({
  defaultStatus = "idle",
  feedbackText = "Feedback Received!",
  negativeText = "Sorry about that",
  onFeedback,
  onUndo,
  theme = "system",
  compact,
  fontFamily = Platform.OS === "web" ? "Arial" : undefined,
  iconSize = 32,
  radius = 999,
  colors: overrides,
  style,
  testID,
}: QuickFeedbackProps) {
  const [status, setStatus] = useState<FeedbackStatus>(defaultStatus)
  const [bounds, setBounds] = useState({
    up: { width: 0, height: 0 },
    down: { width: 0, height: 0 },
  })
  const scheme = useColorScheme(),
    window = useWindowDimensions()
  const small = compact ?? window.width < 640
  const dark = theme === "dark" || (theme === "system" && scheme === "dark")
  const colors = {
    surface: dark ? "#171717" : "#F3EFE9",
    text: dark ? "#f5f5f5" : "#020200",
    icon: dark ? "#f5f5f5" : "#020200de",
    undo: dark ? "#262626" : "#E0DCD4",
    undoHover: dark ? "#404040" : "#d6d2ca",
    undoText: dark ? "#e5e5e5" : "#020200",
    ...overrides,
  }
  const idleWidth = iconSize + 100,
    idleHeight = iconSize + 24
  const activeWidth = (name: "up" | "down") =>
    44 + iconSize + bounds[name].width
  const activeHeight = (name: "up" | "down") =>
    Math.max(iconSize, bounds[name].height) + 28
  const total = useSharedValue(
    defaultStatus === "idle" ? idleWidth * 2 + 8 : activeWidth(defaultStatus)
  )
  const height = useSharedValue(
    defaultStatus === "idle" ? idleHeight : activeHeight(defaultStatus)
  )
  useEffect(() => {
    total.set(
      withSpring(
        status === "idle" ? idleWidth * 2 + 8 : activeWidth(status),
        SPRING
      )
    )
    height.set(
      withSpring(status === "idle" ? idleHeight : activeHeight(status), SPRING)
    )
  }, [status, bounds, small, iconSize])
  const container = useAnimatedStyle(() => ({
    width: total.get(),
    height: height.get(),
  }))
  function select(name: "up" | "down") {
    setStatus(name)
    onFeedback?.(name)
  }
  function undo() {
    setStatus("idle")
    onUndo?.()
  }
  return (
    <Animated.View
      testID={testID}
      style={[{ alignSelf: "center" }, container, style]}
    >
      {(["up", "down"] as const).map((name) => (
        <FeedbackButton
          key={name}
          name={name}
          status={status}
          text={name === "up" ? feedbackText : negativeText}
          width={activeWidth(name)}
          idleWidth={idleWidth}
          idleHeight={idleHeight}
          activeHeight={activeHeight(name)}
          small={small}
          fontFamily={fontFamily}
          iconSize={iconSize}
          radius={radius}
          colors={colors}
          select={() => select(name)}
          undo={undo}
          measure={(width, height) =>
            setBounds((previous) =>
              previous[name].width === width && previous[name].height === height
                ? previous
                : { ...previous, [name]: { width, height } }
            )
          }
        />
      ))}
    </Animated.View>
  )
}
type ButtonProps = {
  name: "up" | "down"
  status: FeedbackStatus
  text: string
  width: number
  idleWidth: number
  idleHeight: number
  activeHeight: number
  small: boolean
  fontFamily?: string
  iconSize: number
  radius: number
  colors: Required<NonNullable<QuickFeedbackProps["colors"]>>
  select: () => void
  undo: () => void
  measure: (width: number, height: number) => void
}
function FeedbackButton({
  name,
  status,
  text,
  width,
  idleWidth,
  idleHeight,
  activeHeight,
  small,
  fontFamily,
  iconSize,
  radius,
  colors,
  select,
  undo,
  measure,
}: ButtonProps) {
  const active = status === name,
    visible = status === "idle" || active
  const progress = useSharedValue(active ? 1 : 0)
  const x = useSharedValue(
    status === "idle" && name === "down" ? idleWidth + 8 : 0
  )
  const w = useSharedValue(active ? width : idleWidth)
  const h = useSharedValue(active ? activeHeight : idleHeight)
  const [hovered, setHovered] = useState(false)
  const pressed = useSharedValue(1)
  const wasVisible = useRef(visible)
  useEffect(() => {
    const entering = visible && !wasVisible.current
    wasVisible.current = visible
    const targetX = status === "idle" && name === "down" ? idleWidth + 8 : 0
    if (!visible || entering) {
      x.set(targetX)
      w.set(active ? width : idleWidth)
      h.set(active ? activeHeight : idleHeight)
      progress.set(active ? 1 : 0)
      return
    }
    progress.set(withSpring(active ? 1 : 0, SPRING))
    x.set(withSpring(targetX, SPRING))
    w.set(withSpring(active ? width : idleWidth, SPRING))
    h.set(withSpring(active ? activeHeight : idleHeight, SPRING))
  }, [
    active,
    visible,
    status,
    width,
    idleWidth,
    activeHeight,
    idleHeight,
    name,
  ])
  const shell = useAnimatedStyle(() => ({
    width: w.get(),
    height: h.get(),
    transform: [{ translateX: x.get() }],
  }))
  const icon = useAnimatedStyle(() => ({ left: 52 - 30 * progress.get() }))
  const message = useAnimatedStyle(() => ({
    opacity: Math.max(0, Math.min(1, progress.get())),
    transform: [{ translateX: 20 * (1 - progress.get()) }],
  }))
  const undoMotion = useAnimatedStyle(() => ({
    transform: [{ scale: pressed.get() }],
  }))
  const textStyle = {
    fontFamily,
    fontSize: small ? 14 : 18,
    lineHeight: small ? 20 : 28,
    fontWeight: "700" as const,
    letterSpacing: 0.025 * (small ? 14 : 18),
    color: colors.text,
    marginLeft: small ? 4 : 8,
  }
  function content(measureOnly: boolean) {
    return (
      <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
        <Text numberOfLines={1} style={textStyle}>
          {text}
        </Text>
        <Pressable
          disabled={measureOnly || !active}
          accessibilityRole="button"
          accessibilityLabel="Undo feedback"
          onPress={undo}
          onHoverIn={() => setHovered(true)}
          onHoverOut={() => setHovered(false)}
          onPressIn={() => pressed.set(withTiming(0.95, BUTTON_TRANSITION))}
          onPressOut={() => pressed.set(withTiming(1, BUTTON_TRANSITION))}
        >
          <Animated.View
            style={[
              {
                flexDirection: "row",
                gap: 4,
                alignItems: "center",
                borderRadius: radius,
                backgroundColor: hovered ? colors.undoHover : colors.undo,
                paddingHorizontal: 12,
                paddingVertical: 8,
                transitionProperty: "backgroundColor",
                transitionDuration: 150,
                transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
              },
              undoMotion,
            ]}
          >
            <Svg
              width={small ? 16 : 20}
              height={small ? 16 : 20}
              viewBox="0 0 24 24"
              fill="none"
              stroke={colors.undoText}
              strokeWidth={2.5}
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <Path d="M3 7v6h6" />
              <Path d="M21 17a9 9 0 0 0-15.45-6.36L3 13" />
            </Svg>
            <Text
              style={{
                fontFamily,
                fontSize: small ? 12 : 16,
                lineHeight: small ? 16 : 24,
                fontWeight: "700",
                color: colors.undoText,
              }}
            >
              Undo
            </Text>
          </Animated.View>
        </Pressable>
      </View>
    )
  }
  return (
    <>
      <View
        pointerEvents="none"
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        style={{ position: "absolute", opacity: 0, flexDirection: "row" }}
        onLayout={(e) =>
          measure(e.nativeEvent.layout.width, e.nativeEvent.layout.height)
        }
      >
        {content(true)}
      </View>
      <Animated.View
        pointerEvents={visible ? "auto" : "none"}
        accessibilityElementsHidden={!visible}
        importantForAccessibility={visible ? "auto" : "no-hide-descendants"}
        style={[
          {
            position: "absolute",
            top: 0,
            opacity: visible ? 1 : 0,
            overflow: "hidden",
            borderRadius: radius,
            backgroundColor: colors.surface,
            justifyContent: "center",
          },
          shell,
        ]}
      >
        <Animated.View
          style={[
            {
              position: "absolute",
              top: 0,
              bottom: 0,
              justifyContent: "center",
            },
            icon,
          ]}
        >
          <Pressable
            onPress={select}
            disabled={active}
            accessibilityRole="button"
            accessibilityLabel={
              name === "up" ? "Positive feedback" : "Negative feedback"
            }
            hitSlop={12}
          >
            <Svg width={iconSize} height={iconSize} viewBox="0 0 512 512">
              <Path d={THUMBS[name]} fill={colors.icon} />
            </Svg>
          </Pressable>
        </Animated.View>
        <Animated.View
          pointerEvents={active ? "auto" : "none"}
          accessibilityElementsHidden={!active}
          importantForAccessibility={active ? "auto" : "no-hide-descendants"}
          style={[
            {
              position: "absolute",
              left: 22 + iconSize + 4,
              top: 0,
              bottom: 0,
              justifyContent: "center",
              width: width - 44 - iconSize,
            },
            message,
          ]}
        >
          {content(false)}
        </Animated.View>
      </Animated.View>
    </>
  )
}
export default QuickFeedback

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

Back to Watermelon UI