Interaction
Filter Disclosure
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.
filter-disclosure.in-progress.tsx
import React, {
useEffect,
useLayoutEffect,
useRef,
useState,
type ComponentType,
} from "react"
import {
Platform,
Pressable,
StyleSheet,
Text,
View,
useColorScheme,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
Easing,
ReduceMotion,
defineAnimation,
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
type AnimationCallback,
} from "react-native-reanimated"
import Svg, { Path } from "react-native-svg"
const ICONS = {
tasks: {
viewBox: "0 0 512 512",
paths: [
"M139.61 35.5a12 12 0 0 0-17 0L58.93 98.81l-22.7-22.12a12 12 0 0 0-17 0L3.53 92.41a12 12 0 0 0 0 17l47.59 47.4a12.78 12.78 0 0 0 17.61 0l15.59-15.62L156.52 69a12.09 12.09 0 0 0 .09-17zm0 159.19a12 12 0 0 0-17 0l-63.68 63.72-22.7-22.1a12 12 0 0 0-17 0L3.53 252a12 12 0 0 0 0 17L51 316.5a12.77 12.77 0 0 0 17.6 0l15.7-15.69 72.2-72.22a12 12 0 0 0 .09-16.9zM64 368c-26.49 0-48.59 21.5-48.59 48S37.53 464 64 464a48 48 0 0 0 0-96zm432 16H208a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h288a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-320H208a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h288a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16zm0 160H208a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h288a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16z",
],
},
events: {
viewBox: "0 0 512 512",
paths: [
"M480 128a64 64 0 0 0-64-64h-16V48.45c0-8.61-6.62-16-15.23-16.43A16 16 0 0 0 368 48v16H144V48.45c0-8.61-6.62-16-15.23-16.43A16 16 0 0 0 112 48v16H96a64 64 0 0 0-64 64v12a4 4 0 0 0 4 4h440a4 4 0 0 0 4-4zM32 416a64 64 0 0 0 64 64h320a64 64 0 0 0 64-64V179a3 3 0 0 0-3-3H35a3 3 0 0 0-3 3zm344-208a24 24 0 1 1-24 24 24 24 0 0 1 24-24m0 80a24 24 0 1 1-24 24 24 24 0 0 1 24-24m-80-80a24 24 0 1 1-24 24 24 24 0 0 1 24-24m0 80a24 24 0 1 1-24 24 24 24 0 0 1 24-24m0 80a24 24 0 1 1-24 24 24 24 0 0 1 24-24m-80-80a24 24 0 1 1-24 24 24 24 0 0 1 24-24m0 80a24 24 0 1 1-24 24 24 24 0 0 1 24-24m-80-80a24 24 0 1 1-24 24 24 24 0 0 1 24-24m0 80a24 24 0 1 1-24 24 24 24 0 0 1 24-24",
],
},
reminders: {
viewBox: "0 0 448 512",
paths: [
"M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71z",
],
},
appointments: {
viewBox: "0 0 16 16",
paths: [
"M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5c0 .276-.224 1.5-.5 1.5s-.5-1.224-.5-1.5V10h-4a.5.5 0 0 1-.5-.5c0-.973.64-1.725 1.17-2.189A6 6 0 0 1 5 6.708V2.277a3 3 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354",
],
},
meetings: {
viewBox: "0 0 16 16",
paths: [
"M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6m-5.784 6A2.24 2.24 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.3 6.3 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1zM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5",
],
},
celebrations: {
viewBox: "0 0 24 24",
paths: [
"M16 16C17.6569 16 19 17.3431 19 19C19 20.6569 17.6569 22 16 22C14.3431 22 13 20.6569 13 19C13 17.3431 14.3431 16 16 16ZM6 12C8.20914 12 10 13.7909 10 16C10 18.2091 8.20914 20 6 20C3.79086 20 2 18.2091 2 16C2 13.7909 3.79086 12 6 12ZM14.5 2C17.5376 2 20 4.46243 20 7.5C20 10.5376 17.5376 13 14.5 13C11.4624 13 9 10.5376 9 7.5C9 4.46243 11.4624 2 14.5 2Z",
],
},
funnel: {
viewBox: "0 0 256 256",
paths: [
"M204,136a12,12,0,0,1-12,12H64a12,12,0,0,1,0-24H192A12,12,0,0,1,204,136Zm28-60H24a12,12,0,0,0,0,24H232a12,12,0,0,0,0-24Zm-80,96H104a12,12,0,0,0,0,24h48a12,12,0,0,0,0-24Z",
],
},
check: {
viewBox: "0 0 16 16",
paths: [
"M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425z",
],
},
} as const
const WEB = Platform.OS === "web"
const FONT = WEB ? "Arial" : Platform.OS === "ios" ? "Helvetica" : "sans-serif"
const clamp = (n: number) => {
"worklet"
return Math.max(0, Math.min(1, n))
}
function disclosureCurve(t: number) {
"worklet"
const seconds = t * 0.7,
decay = 20.095464266064543 / 2
const angular = Math.sqrt(179.47897069718974 - decay * decay)
return (
1 -
Math.exp(-decay * seconds) *
(Math.cos(angular * seconds) +
(decay / angular) * Math.sin(angular * seconds))
)
}
function badgeCurve(t: number) {
"worklet"
const x = (15.389022460752644 / 2) * t * 1.2
return 1 - Math.exp(-x) * (1 + x)
}
const MORPH = {
duration: 700,
easing: disclosureCurve,
reduceMotion: ReduceMotion.System,
} as const
const BADGE = {
duration: 1200,
easing: badgeCurve,
reduceMotion: ReduceMotion.System,
} as const
const HOVER = {
duration: 150,
easing: Easing.bezier(0.4, 0, 0.2, 1),
reduceMotion: ReduceMotion.System,
} as const
type SpringState = {
current: number
toValue: number
velocity: number
origin: number
started: number
lastTimestamp: number
initialVelocity: number
callback?: AnimationCallback
onStart: (
a: SpringState,
value: number,
now: number,
previous?: SpringState
) => void
onFrame: (a: SpringState, now: number) => boolean
}
function motionSpring(
target: number,
stiffness: number,
damping: number,
callback?: AnimationCallback,
delay = 0
): number {
"worklet"
return defineAnimation<SpringState>(target, () => {
"worklet"
return {
current: target,
toValue: target,
velocity: 0,
origin: target,
started: 0,
lastTimestamp: 0,
initialVelocity: 0,
callback,
onStart(a, value, now, previous) {
a.current = a.origin = value
a.started = now + delay
a.lastTimestamp = now
a.initialVelocity = previous?.lastTimestamp ? previous.velocity : 0
},
onFrame(a, now) {
if (now < a.started) return false
const t = (now - a.started) / 1000,
decay = damping / 2
const angular = Math.sqrt(stiffness - decay * decay),
x = a.origin - a.toValue
const b = (a.initialVelocity + decay * x) / angular,
envelope = Math.exp(-decay * t)
const cos = Math.cos(angular * t),
sin = Math.sin(angular * t)
a.current = a.toValue + envelope * (x * cos + b * sin)
a.velocity =
envelope *
((b * angular - decay * x) * cos - (x * angular + decay * b) * sin)
a.lastTimestamp = now
const small = Math.abs(a.origin - a.toValue) < 5
if (
Math.abs(a.velocity) <= (small ? 0.01 : 2) &&
Math.abs(a.current - a.toValue) <= (small ? 0.005 : 0.5)
) {
a.current = a.toValue
a.lastTimestamp = a.velocity = 0
return true
}
return false
},
}
}) as unknown as number
}
export type FilterIconProps = { size: number; color: string }
export type FilterItem = {
id: string
label: string
icon?: ComponentType<FilterIconProps>
}
export type FilterDisclosureColors = {
surface: string
border: string
label: string
icon: string
funnel: string
hover: string
ring: string
selected: string
check: string
}
export type FilterDisclosureProps = {
items?: FilterItem[]
defaultActiveId?: string
onChange?: (id: string) => void
theme?: "light" | "dark" | "system"
colors?: Partial<FilterDisclosureColors>
width?: number
radius?: number
buttonSize?: number
fontSize?: number
style?: StyleProp<ViewStyle>
testID?: string
}
const DEFAULT_ITEMS: FilterItem[] = [
{ id: "tasks", label: "Tasks" },
{ id: "events", label: "Events" },
{ id: "reminders", label: "Reminders" },
{ id: "appointments", label: "Appointment" },
{ id: "meetings", label: "Mettings" },
{ id: "celebrations", label: "Celebrations" },
]
const LIGHT: FilterDisclosureColors = {
surface: "#FEFEFE",
border: "#E5E5E9",
label: "#535257",
icon: "#AFAEB9",
funnel: "#272729",
hover: "#F6F5FA",
ring: "#ADADB2",
selected: "#31C051",
check: "#fff",
}
const DARK: FilterDisclosureColors = {
...LIGHT,
surface: "#171717",
border: "#262626",
label: "#e5e5e5",
icon: "#737373",
funnel: "#f5f5f5",
hover: "rgba(38,38,38,0.6)",
ring: "#404040",
}
function Icon({ name, size, color }: FilterIconProps & { name: string }) {
const data = ICONS[name as keyof typeof ICONS] ?? ICONS.tasks
return (
<Svg width={size} height={size} viewBox={data.viewBox} pointerEvents="none">
{data.paths.map((d, i) => (
<Path key={i} d={d} fill={color} />
))}
</Svg>
)
}
function ItemIcon({
item,
size,
color,
}: FilterIconProps & { item: FilterItem | undefined }) {
const Custom = item?.icon
return Custom ? (
<Custom size={size} color={color} />
) : (
<Icon name={item?.id ?? "tasks"} size={size} color={color} />
)
}
function Row({
item,
index,
selected,
colors,
fontSize,
onSelect,
}: {
item: FilterItem
index: number
selected: boolean
colors: FilterDisclosureColors
fontSize: number
onSelect: () => void
}) {
const y = useSharedValue(40),
scale = useSharedValue(1.1),
opacity = useSharedValue(0)
const press = useSharedValue(1),
hover = useSharedValue(0)
const checkScale = useSharedValue(selected ? 1 : 0),
checkOpacity = useSharedValue(selected ? 100 : 0)
const color = useSharedValue(selected ? 1 : 0)
useLayoutEffect(() => {
const delay = (3 + index) * 50
y.value = motionSpring(0, 240, 20, undefined, delay)
scale.value = motionSpring(1, 240, 20, undefined, delay)
opacity.value = motionSpring(100, 240, 20, undefined, delay)
}, [index, y, scale, opacity])
useLayoutEffect(() => {
checkScale.value = motionSpring(selected ? 1 : 0, 520, 30)
checkOpacity.value = motionSpring(selected ? 100 : 0, 520, 30)
color.value = withTiming(selected ? 1 : 0, MORPH)
}, [selected, checkScale, checkOpacity, color])
const animated = useAnimatedStyle(() => ({
opacity: clamp(opacity.value / 100),
transform: [{ translateY: y.value }, { scale: scale.value * press.value }],
}))
const background = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
hover.value,
[0, 1],
["transparent", colors.hover]
),
}))
const bubble = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
clamp(color.value),
[0, 1],
["rgba(49,192,81,0)", colors.selected]
),
}))
const mark = useAnimatedStyle(() => ({
opacity: clamp(checkOpacity.value / 100),
transform: [{ scale: checkScale.value }],
}))
return (
<Animated.View style={animated}>
<Pressable
accessibilityRole="radio"
accessibilityLabel={item.label}
accessibilityState={{ checked: selected }}
onPress={onSelect}
onHoverIn={() => {
hover.value = withTiming(1, HOVER)
}}
onHoverOut={() => {
hover.value = withTiming(0, HOVER)
}}
onPressIn={() => {
press.value = motionSpring(0.98, 240, 20, undefined, (3 + index) * 50)
}}
onPressOut={() => {
press.value = motionSpring(1, 240, 20, undefined, (3 + index) * 50)
}}
>
<Animated.View style={[styles.row, background]}>
<View style={styles.labelGroup}>
<ItemIcon item={item} size={24} color={colors.icon} />
<Text
numberOfLines={1}
style={{
fontFamily: FONT,
fontSize,
lineHeight: fontSize * 1.5,
letterSpacing: -fontSize * 0.025,
fontWeight: "700",
color: colors.label,
}}
>
{item.label}
</Text>
</View>
<Animated.View
style={[
styles.bubble,
bubble,
{ borderColor: selected ? colors.selected : colors.ring },
]}
>
<Animated.View style={mark}>
<Icon name="check" size={16} color={colors.check} />
</Animated.View>
</Animated.View>
</Animated.View>
</Pressable>
</Animated.View>
)
}
export function FilterDisclosure({
items = DEFAULT_ITEMS,
defaultActiveId = "reminders",
onChange,
theme = "system",
colors: overrides,
width = 300,
radius = 32,
buttonSize = 60,
fontSize = 18,
style,
testID,
}: FilterDisclosureProps) {
const system = useColorScheme(),
dark = theme === "dark" || (theme === "system" && system === "dark")
const colors = { ...(dark ? DARK : LIGHT), ...overrides }
const [open, setOpen] = useState(false),
[active, setActive] = useState(defaultActiveId)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const rootRef = useRef<View>(null),
[border, setBorder] = useState(1.6)
const progress = useSharedValue(0),
fade = useSharedValue(1),
feedback = useSharedValue(1),
badge = useSharedValue(-30)
const mounted = useRef(false)
const { fontScale } = useWindowDimensions()
const rowHeight = Math.max(26, fontSize * 1.5 * fontScale) + 20
const panelHeight =
items.length * rowHeight +
Math.max(0, items.length - 1) * 4 +
16 +
border * 2
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current)
},
[]
)
useLayoutEffect(() => {
if (WEB && rootRef.current) {
const node = rootRef.current as unknown as Element
const actual = parseFloat(window.getComputedStyle(node).borderTopWidth)
if (Number.isFinite(actual) && actual !== border) setBorder(actual)
}
}, [border])
useLayoutEffect(() => {
if (open) feedback.value = 1
progress.value = withTiming(open ? 1 : 0, MORPH)
if (mounted.current) {
fade.value = 0
fade.value = withTiming(1, MORPH)
}
mounted.current = true
if (!open) {
badge.value = -30
badge.value = withTiming(0, BADGE)
}
}, [open, progress, fade, badge, feedback])
const shape = useAnimatedStyle(() => {
const p = progress.value,
w = buttonSize + (width - buttonSize) * p,
h = buttonSize + (panelHeight - buttonSize) * p
return {
width: w,
height: h,
left: (width - w) / 2 - ((buttonSize - 12) / 2) * (1 - p),
top: (70 - h) / 2,
borderRadius: Math.min(radius, w / 2, h / 2),
transform: [{ scale: feedback.value }],
}
})
const content = useAnimatedStyle(() => ({
left:
(buttonSize + (width - buttonSize) * progress.value - width) / 2 - border,
top:
(buttonSize + (panelHeight - buttonSize) * progress.value - panelHeight) /
2 -
border,
transform: [
{ scaleX: (buttonSize + (width - buttonSize) * progress.value) / width },
{
scaleY:
(buttonSize + (panelHeight - buttonSize) * progress.value) /
panelHeight,
},
],
opacity: open ? clamp(fade.value) : 0,
}))
const funnel = useAnimatedStyle(() => ({
opacity: open ? 0 : clamp(fade.value),
transform: [
{ translateX: ((buttonSize - 12) / 2) * progress.value },
{
scaleX:
(feedback.value *
(buttonSize + (width - buttonSize) * progress.value)) /
buttonSize,
},
{
scaleY:
(feedback.value *
(buttonSize + (panelHeight - buttonSize) * progress.value)) /
buttonSize,
},
],
}))
const badgeStyle = useAnimatedStyle(() => ({
transform: [{ translateX: badge.value }],
opacity: open ? 0 : 0.8,
}))
const select = (id: string) => {
if (timer.current) clearTimeout(timer.current)
setActive(id)
onChange?.(id)
timer.current = setTimeout(() => {
timer.current = undefined
setOpen(false)
}, 220)
}
return (
<View
testID={testID}
style={[{ width, height: 70, overflow: "visible" }, style]}
>
<Animated.View
pointerEvents="none"
style={[
styles.badge,
{
left: width / 2 - 6,
top: (70 - buttonSize) / 2,
width: buttonSize,
height: buttonSize,
borderRadius: buttonSize / 2,
borderColor: colors.border,
backgroundColor: colors.surface,
},
badgeStyle,
]}
>
<ItemIcon
item={items.find((item) => item.id === active)}
size={24}
color={colors.icon}
/>
</Animated.View>
<Animated.View
ref={rootRef}
pointerEvents={open ? "auto" : "none"}
style={[
styles.surface,
{
borderColor: colors.border,
backgroundColor: colors.surface,
boxShadow:
dark && open
? "0px 20px 50px rgba(0,0,0,.5)"
: open
? "0px 12px 40px rgba(0,0,0,.08)"
: "0px 1px 2px rgba(0,0,0,.05)",
},
shape,
]}
>
{open && (
<Animated.View
accessibilityRole="radiogroup"
style={[
{
position: "absolute",
width,
height: panelHeight,
padding: 8 + border,
gap: 4,
transformOrigin: "center",
zIndex: 20,
},
content,
]}
>
{items.map((item, index) => (
<Row
key={item.id}
item={item}
index={index}
selected={active === item.id}
colors={colors}
fontSize={fontSize}
onSelect={() => select(item.id)}
/>
))}
</Animated.View>
)}
</Animated.View>
{!open && (
<Animated.View
style={[
{
position: "absolute",
width: buttonSize,
height: buttonSize,
left: width / 2 - buttonSize + 6,
top: (70 - buttonSize) / 2,
zIndex: 30,
},
funnel,
]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel="Open filters"
accessibilityState={{ expanded: open }}
onPress={() => setOpen(true)}
onHoverIn={() => {
feedback.value = withTiming(1.05, MORPH)
}}
onHoverOut={() => {
feedback.value = withTiming(1, MORPH)
}}
onPressIn={() => {
feedback.value = withTiming(0.95, MORPH)
}}
onPressOut={() => {
feedback.value = withTiming(1, MORPH)
}}
style={styles.funnel}
>
<Icon name="funnel" size={30} color={colors.funnel} />
</Pressable>
</Animated.View>
)}
</View>
)
}
const styles = StyleSheet.create({
surface: {
position: "absolute",
borderWidth: 1.6,
zIndex: 15,
overflow: "hidden",
},
badge: {
position: "absolute",
borderWidth: 1.6,
alignItems: "center",
justifyContent: "center",
zIndex: 10,
boxShadow: "0px 1px 2px rgba(0,0,0,.05)",
},
funnel: {
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderRadius: 16,
paddingHorizontal: 12,
paddingVertical: 10,
},
labelGroup: {
flexDirection: "row",
alignItems: "center",
gap: 28,
flexShrink: 1,
},
bubble: {
width: 26,
height: 26,
borderWidth: 3,
borderRadius: 13,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
})
Give the gestures and motion a run on a real Android phone.