Interaction
Quick Switcher
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-switcher.in-progress.tsx
import React, { useCallback, useEffect, useId, useRef, useState } from "react"
import {
Platform,
Pressable,
Text,
TextInput,
View,
useColorScheme,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
defineAnimation,
type AnimationObject,
cubicBezier,
useAnimatedProps,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, {
Defs,
Filter,
FeGaussianBlur,
G,
Path,
Text as SvgText,
} from "react-native-svg"
type DelayedMotion = AnimationObject<number> & {
current: number
startedAt: number
started: boolean
held: boolean
next: AnimationObject<number>
previous?: AnimationObject<number>
}
function motionDelay(delay: number, animation: number): number {
"worklet"
const definition = animation as unknown as
AnimationObject<number> | (() => AnimationObject<number>)
return defineAnimation<DelayedMotion>(animation, () => {
"worklet"
const next = typeof definition === "function" ? definition() : definition
return {
isHigherOrder: true,
current: next.current ?? 0,
startedAt: 0,
started: false,
held: false,
next,
onStart(a, value, now, previous) {
a.current = value
a.startedAt = now
a.started = false
a.held = false
const prior = previous?.next
? previous.started
? previous.next
: previous.previous
: previous
const stationary =
previous?.next &&
!previous.started &&
(previous.held || now - previous.startedAt > 30)
a.previous = prior
? {
...prior,
current: value,
velocity: stationary ? 0 : prior.velocity,
}
: undefined
if (next.reduceMotion === undefined) next.reduceMotion = a.reduceMotion
},
onFrame(a, now) {
if (!a.started) {
if (!a.reduceMotion && now - a.startedAt < delay) {
a.held = true
return false
}
const start = a.reduceMotion ? now : a.startedAt + delay
const previous = a.previous
? {
...a.previous,
lastTimestamp: start,
startTimestamp: 0,
startTime: 0,
}
: undefined
next.onStart(next, a.current, start, previous)
a.started = true
a.previous = undefined
}
const finished = next.onFrame(next, now)
a.current = next.current ?? a.current
return finished
},
callback: next.callback,
}
}) as unknown as number
}
export type QuickSwitcherMode = "ask" | "generate"
export interface QuickSwitcherProps {
defaultMode?: QuickSwitcherMode
askIcon?: React.ReactNode
generateIcon?: React.ReactNode
askLabel?: string
generateLabel?: string
onActionClick?: (mode: QuickSwitcherMode, value: string) => void
onModeChange?: (mode: QuickSwitcherMode) => void
onChangeText?: (value: string) => void
compact?: boolean
width?: number
radius?: number
fontFamily?: string
theme?: "light" | "dark" | "system"
colors?: Partial<{
surface: string
button: string
hover: string
pressed: string
actionBorder: string
border: string
text: string
muted: string
chevron: string
}>
style?: StyleProp<ViewStyle>
testID?: string
}
class BlurTarget extends FeGaussianBlur {
setNativeProps = (
props: React.ComponentProps<typeof FeGaussianBlur> & {
style?: React.ComponentProps<typeof FeGaussianBlur>
}
) => {
if (Platform.OS === "web") {
const node = (
this as unknown as {
elementRef?: {
current: {
setAttribute: (key: string, value: string) => void
} | null
}
}
).elementRef?.current
const amount = props.style?.stdDeviation ?? props.stdDeviation
if (amount !== undefined)
node?.setAttribute("stdDeviation", String(amount))
} else this.root?.setNativeProps(props)
}
}
const AB = Animated.createAnimatedComponent(BlurTarget),
AG = Animated.createAnimatedComponent(G)
const ICON_SPRING = { stiffness: 300, damping: 30, mass: 0.8 }
const LETTER_SPRING = { stiffness: 240, damping: 16, mass: 1.2 }
type WordEntry = { id: number; text: string; exit: boolean }
export function QuickSwitcher({
defaultMode = "ask",
askIcon,
generateIcon,
askLabel = "Ask Anything",
generateLabel = "Generate Image",
onActionClick,
onModeChange,
onChangeText,
compact,
width,
radius = 999,
fontFamily = Platform.OS === "web" ? "Arial" : undefined,
theme = "system",
colors: overrides,
style,
testID,
}: QuickSwitcherProps) {
const [mode, setMode] = useState(defaultMode),
[value, setValue] = useState("")
const window = useWindowDimensions(),
scheme = useColorScheme(),
small = compact ?? window.width < 640
const dark = theme === "dark" || (theme === "system" && scheme === "dark")
const colors = {
surface: dark ? "#171717" : "#F2F2F2",
button: dark ? "#262626" : "#fff",
hover: dark ? "#404040" : "#f5f5f5",
pressed: dark ? "#262626" : "#fafafa",
actionBorder: dark ? "#404040" : "#f3f4f6",
border: dark ? "#26262680" : "#e5e7eb80",
text: dark ? "#f5f5f5" : "#171717",
muted: dark ? "#737373" : "#a3a3a3",
chevron: dark ? "#737373" : "#d4d4d4",
...overrides,
}
const [modeHover, setModeHover] = useState(false),
[actionHover, setActionHover] = useState(false),
[actionPressed, setActionPressed] = useState(false)
const modeScale = useSharedValue(1),
actionScale = useSharedValue(1),
actionX = useSharedValue(0)
const modeMotion = useAnimatedStyle(() => ({
transform: [{ scale: modeScale.get() }],
}))
const actionMotion = useAnimatedStyle(() => ({
transform: [{ translateX: actionX.get() }, { scale: actionScale.get() }],
}))
const label = mode === "ask" ? askLabel : generateLabel
const nextId = useRef(1)
const [words, setWords] = useState<WordEntry[]>([
{ id: 0, text: label, exit: false },
])
const [glyphWidths, setGlyphWidths] = useState<Record<string, number>>({})
const lastTarget = useRef(label)
const size = small ? 18 : 20
useEffect(() => {
const target = value.length ? "" : label
if (lastTarget.current === target) return
lastTarget.current = target
setWords((current) => {
const returning = current.some((entry) => entry.text === target)
const words = current.map((entry) => ({
...entry,
exit: entry.text !== target,
}))
return target && !returning
? [...words, { id: nextId.current++, text: target, exit: false }]
: words
})
}, [label, value])
const removeWord = useCallback(
(id: number) =>
setWords((current) =>
current.filter((entry) => entry.id !== id || !entry.exit)
),
[]
)
function toggle() {
const next = mode === "ask" ? "generate" : "ask"
setMode(next)
onModeChange?.(next)
}
function change(text: string) {
setValue(text)
onChangeText?.(text)
}
function submit() {
onActionClick?.(mode, value)
}
return (
<View
testID={testID}
style={[
{
alignSelf: "center",
width: width ?? (small ? 320 : 380),
height: 68,
borderRadius: radius,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
padding: 6,
flexDirection: "row",
alignItems: "center",
boxShadow: "0px 1px 2px rgba(0,0,0,.05)",
},
style,
]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Switch from ${mode} mode`}
onPress={toggle}
onHoverIn={() => setModeHover(true)}
onHoverOut={() => setModeHover(false)}
onPressIn={() => modeScale.set(0.95)}
onPressOut={() => modeScale.set(1)}
>
<Animated.View
style={[
{
height: 54,
flexDirection: "row",
alignItems: "center",
borderRadius: radius,
paddingLeft: 8,
paddingRight: 16,
backgroundColor: modeHover ? colors.hover : colors.button,
transitionProperty: "backgroundColor",
transitionDuration: 150,
transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
boxShadow: "0px 1px 2px rgba(0,0,0,.05)",
},
modeMotion,
]}
>
<View style={{ width: 48, height: 48, overflow: "hidden" }}>
<ModeIcon active={mode === "ask"} color={colors.text}>
{askIcon ?? (
<Path
d="M5 5h14v10H9l-4 4V5Z"
fill="none"
stroke={colors.text}
strokeWidth={2}
/>
)}
</ModeIcon>
<ModeIcon active={mode === "generate"} color={colors.text}>
{generateIcon ?? (
<>
<Path
d="M4 4h16v16H4Z"
fill="none"
stroke={colors.text}
strokeWidth={2}
/>
<Path
d="m4 16 5-5 4 4 3-3 4 4"
fill="none"
stroke={colors.text}
strokeWidth={2}
/>
</>
)}
</ModeIcon>
</View>
<View style={{ marginLeft: 4 }}>
{[true, false].map((up) => (
<Svg
key={String(up)}
width={14}
height={14}
viewBox="0 0 24 24"
fill="none"
stroke={colors.chevron}
strokeWidth={4}
strokeLinecap="round"
strokeLinejoin="round"
>
<Path d={up ? "m18 15-6-6-6 6" : "m6 9 6 6 6-6"} />
</Svg>
))}
</View>
</Animated.View>
</Pressable>
<View
style={{
flex: 1,
height: 54,
paddingHorizontal: 16,
justifyContent: "center",
overflow: "hidden",
}}
>
<View
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={{
position: "absolute",
left: 16,
top: 3,
height: 48,
flexDirection: "row",
opacity: 0,
}}
>
{Array.from(new Set((askLabel + generateLabel).split(""))).map(
(char) => (
<Text
key={`${size}-${char}`}
onLayout={(e) => {
const measured = e.nativeEvent.layout.width
setGlyphWidths((previous) =>
previous[`${size}-${char}`] === measured
? previous
: { ...previous, [`${size}-${char}`]: measured }
)
}}
style={{ fontFamily, fontSize: size, fontWeight: "600" }}
>
{char}
</Text>
)
)}
</View>
{words.map((entry) => (
<View
key={entry.id}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={{
position: "absolute",
top: 3,
left: 16,
flexDirection: "row",
height: 48,
}}
>
{entry.text.split("").map((char, i) => (
<PlaceholderGlyph
key={i}
char={char}
index={i}
width={
glyphWidths[`${size}-${char}`] ??
(char === " " ? size * 0.28 : size * 0.56)
}
size={size}
fontFamily={fontFamily}
color={colors.muted}
exit={entry.exit}
onExit={
i === entry.text.length - 1
? () => removeWord(entry.id)
: undefined
}
/>
))}
</View>
))}
<TextInput
accessibilityLabel={label}
value={value}
onChangeText={change}
onSubmitEditing={submit}
returnKeyType="send"
style={{
padding: 0,
fontFamily,
fontSize: size,
fontWeight: "600",
color: colors.text,
height: 48,
}}
/>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel={mode === "ask" ? "Ask" : "Generate image"}
onPress={submit}
onHoverIn={() => {
setActionHover(true)
actionX.set(withSpring(2, { stiffness: 500, damping: 25 }))
}}
onHoverOut={() => {
setActionHover(false)
actionX.set(withSpring(0, { stiffness: 500, damping: 25 }))
}}
onPressIn={() => {
setActionPressed(true)
actionScale.set(withSpring(0.9, { stiffness: 500, damping: 25 }))
}}
onPressOut={() => {
setActionPressed(false)
actionScale.set(withSpring(1, { stiffness: 500, damping: 25 }))
}}
>
<Animated.View
style={[
{
width: 48,
height: 48,
borderRadius: radius,
borderWidth: 1,
borderColor: colors.actionBorder,
backgroundColor: actionPressed
? colors.pressed
: actionHover
? colors.hover
: colors.button,
transitionProperty: ["backgroundColor", "borderColor"],
transitionDuration: 150,
transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
justifyContent: "center",
alignItems: "center",
boxShadow: "0px 1px 2px rgba(0,0,0,.05)",
},
actionMotion,
]}
>
<Svg
width={22}
height={22}
viewBox="0 0 24 24"
fill="none"
stroke={colors.text}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<Path d="M5 12h14m-7-7 7 7-7 7" />
</Svg>
</Animated.View>
</Pressable>
</View>
)
}
function ModeIcon({
active,
color,
children,
}: {
active: boolean
color: string
children: React.ReactNode
}) {
const id = useId().replace(/\W/g, ""),
progress = useSharedValue(active ? 1 : 0)
useEffect(() => {
progress.set(withSpring(active ? 1 : 0, ICON_SPRING))
}, [active])
const motion = useAnimatedProps(() => ({
opacity: progress.get(),
transform: `translate(12 12) scale(${0.5 + 0.5 * progress.get()}) translate(-12 -12)`,
}))
const blur = useAnimatedProps(() => ({
stdDeviation: 4 * Math.max(0, 1 - progress.get()),
}))
return (
<Svg
width={48}
height={48}
viewBox="-12 -12 48 48"
pointerEvents="none"
style={{ position: "absolute" }}
>
<Defs>
<Filter id={id} x="-70%" y="-70%" width="240%" height="240%">
<AB animatedProps={blur} />
</Filter>
</Defs>
<AG animatedProps={motion}>
<G fill={color} filter={`url(#${id})`}>
{children}
</G>
</AG>
</Svg>
)
}
function PlaceholderGlyph({
char,
index,
width,
size,
fontFamily,
color,
exit,
onExit,
}: {
char: string
index: number
width: number
size: number
fontFamily?: string
color: string
exit: boolean
onExit?: () => void
}) {
const id = useId().replace(/\W/g, ""),
progress = useSharedValue(0),
y = useSharedValue(10)
useEffect(() => {
const delay = index * 14
progress.set(
motionDelay(
delay,
withSpring(exit ? 0 : 1, LETTER_SPRING, (finished) => {
if (finished && exit && onExit) scheduleOnRN(onExit)
})
)
)
y.set(motionDelay(delay, withSpring(exit ? -10 : 0, LETTER_SPRING)))
}, [exit, index])
const motion = useAnimatedProps(() => ({
opacity: Math.max(0, Math.min(1, progress.get())),
transform: `translate(${width / 2} ${24 + y.get()}) scale(${0.5 + 0.5 * progress.get()}) translate(${-width / 2} -24)`,
}))
const blur = useAnimatedProps(() => ({
stdDeviation: 2 * Math.max(0, 1 - progress.get()),
}))
return (
<Svg width={Math.max(1, width)} height={48} style={{ overflow: "visible" }}>
<Defs>
<Filter id={id} x="-100%" y="-30%" width="300%" height="160%">
<AB animatedProps={blur} />
</Filter>
</Defs>
<AG animatedProps={motion}>
<G filter={`url(#${id})`}>
<SvgText
x={0}
y={24 + size * 0.35}
fontFamily={fontFamily}
fontSize={size}
fontWeight="600"
fill={color}
>
{char === " " ? "\u00a0" : char}
</SvgText>
</G>
</AG>
</Svg>
)
}
export default QuickSwitcher
Give the gestures and motion a run on a real Android phone.