Interaction
Quick Feedback
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.
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.