Skip to content
pixelfor Expo
Ctrl K

Interaction

Filter Disclosure

An expressive interaction rebuilt for touch

Try it on your phone

Filter Disclosure

Scan to open Filter Disclosure 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.

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.

Back to Watermelon UI