Skip to content
pixelfor Expo
Ctrl K

Interaction

Inline Overflow

An expressive interaction rebuilt for touch

Try it on your phone

Inline Overflow

Scan to open Inline Overflow 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.

inline-overflow.in-progress.tsx

import React, {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
} from "react"
import {
  Platform,
  Pressable,
  StyleSheet,
  Text,
  View,
  useColorScheme,
  useWindowDimensions,
  type StyleProp,
  type ViewStyle,
} from "react-native"
import Animated, {
  ReduceMotion,
  Easing,
  useSharedValue,
  useDerivedValue,
  useAnimatedStyle,
  useAnimatedProps,
  withTiming,
  type SharedValue,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, {
  Defs,
  Filter,
  FeGaussianBlur,
  G,
  Path,
  Rect,
  Text as SvgText,
} from "react-native-svg"
const WEB = Platform.OS === "web"
const FONT = WEB ? "Arial" : Platform.OS === "ios" ? "Helvetica" : "sans-serif"
const DECAY = 19.289987946236508 / 2
const ANGULAR = Math.sqrt(220.1796656603253 - DECAY * DECAY)
function springCurve(progress: number) {
  "worklet"
  const t = progress * 0.7
  return (
    1 -
    Math.exp(-DECAY * t) *
      (Math.cos(ANGULAR * t) + (DECAY / ANGULAR) * Math.sin(ANGULAR * t))
  )
}
const MORPH = {
  duration: 700,
  easing: springCurve,
  reduceMotion: ReduceMotion.System,
} as const
const COLORS = {
  duration: 150,
  easing: Easing.bezier(0.4, 0, 0.2, 1),
  reduceMotion: ReduceMotion.System,
} as const
const clamp = (p: number) => {
  "worklet"
  return Math.max(0, Math.min(1, p))
}
export type InlineOverflowAction = { label: string; onPress?: () => void }
export type InlineOverflowColors = {
  surface: string
  surfaceBorder: string
  action: string
  actionBorder: string
  text: string
  icon: string
}
const DARK: InlineOverflowColors = {
  surface: "#171717",
  surfaceBorder: "#262626",
  action: "#262626",
  actionBorder: "#404040",
  text: "#f5f5f5",
  icon: "#a3a3a3",
}
const LIGHT: InlineOverflowColors = {
  surface: "#F6F5EE",
  surfaceBorder: "rgba(0,0,0,0.05)",
  action: "#ffffff",
  actionBorder: "rgba(0,0,0,0.05)",
  text: "#0a0a0a",
  icon: "#525252",
}
export type InlineOverflowProps = {
  visibleActions: InlineOverflowAction[]
  hiddenActions: InlineOverflowAction[]
  showThemeToggle?: boolean
  theme?: "light" | "dark" | "system"
  colors?: Partial<InlineOverflowColors>
  size?: "auto" | "compact" | "large"
  maxWidth?: number
  radius?: number
  onOpenChange?: (open: boolean) => void
  style?: StyleProp<ViewStyle>
  testID?: string
}
class BlurTarget extends FeGaussianBlur {
  setNativeProps = (
    props: React.ComponentProps<typeof FeGaussianBlur> & {
      style?: React.ComponentProps<typeof FeGaussianBlur>
    }
  ) => {
    if (WEB) {
      const amount = props.style?.stdDeviation ?? props.stdDeviation
      const element = (
        this as unknown as {
          elementRef?: {
            current: {
              setAttribute: (key: string, value: string) => void
            } | null
          }
        }
      ).elementRef?.current
      if (amount !== undefined)
        element?.setAttribute("stdDeviation", String(amount))
    } else this.root?.setNativeProps(props)
  }
}
const AnimatedBlur = Animated.createAnimatedComponent(BlurTarget)
const AnimatedG = Animated.createAnimatedComponent(G)
const AnimatedRect = Animated.createAnimatedComponent(Rect)
const AnimatedText = Animated.createAnimatedComponent(SvgText)
function Measure({
  label,
  fontSize,
  onWidth,
}: {
  label: string
  fontSize: number
  onWidth: (width: number) => void
}) {
  const ref = useRef<Text>(null)
  return (
    <Text
      ref={ref}
      numberOfLines={1}
      style={{
        fontFamily: FONT,
        fontWeight: "700",
        fontSize,
        lineHeight: fontSize * 1.5,
        alignSelf: "flex-start",
        includeFontPadding: false,
      }}
      onLayout={(e) =>
        onWidth(
          WEB && ref.current
            ? (ref.current as unknown as Element).getBoundingClientRect().width
            : e.nativeEvent.layout.width
        )
      }
    >
      {label}
    </Text>
  )
}
function Action({
  action,
  width,
  height,
  fontSize,
  border,
  left,
  top,
  colors: c,
  exit,
  closing,
  rootWidth,
  closedWidth,
  testID,
}: {
  action: InlineOverflowAction
  width: number
  height: number
  fontSize: number
  border: number
  left: number
  top: SharedValue<number>
  colors: InlineOverflowColors
  exit?: SharedValue<number>
  closing: boolean
  rootWidth: SharedValue<number>
  closedWidth: number
  testID?: string
}) {
  const id = useId().replace(/:/g, "")
  const entry = useSharedValue(0)
  const feedback = useSharedValue(0)
  const hovered = useRef(false)
  const pressed = useRef(false)
  const fill = useSharedValue(c.action)
  const stroke = useSharedValue(c.actionBorder)
  const ink = useSharedValue(c.text)
  const offset = useSharedValue(0)
  const wasClosing = useRef(closing)
  useLayoutEffect(() => {
    if (exit && wasClosing.current && !closing) {
      offset.set(
        (rootWidth.get() / closedWidth - 1) *
          (left + width / 2) *
          (1 - exit.get())
      )
      offset.set(withTiming(0, MORPH))
    }
    wasClosing.current = closing
  }, [closing, closedWidth, left, width, exit, offset, rootWidth])
  useLayoutEffect(() => {
    entry.set(withTiming(1, MORPH))
    feedback.set(withTiming(1, MORPH))
  }, [entry, feedback])
  useEffect(() => {
    fill.set(withTiming(c.action, COLORS))
    stroke.set(withTiming(c.actionBorder, COLORS))
    ink.set(withTiming(c.text, COLORS))
  }, [c.action, c.actionBorder, c.text, fill, stroke, ink])
  const body = useAnimatedStyle(() => ({
    top: top.get(),
    opacity: exit ? clamp(exit.get()) : 1,
    transform: [
      {
        translateX:
          exit && closing
            ? (rootWidth.get() / closedWidth - 1) *
              (left + width / 2) *
              (1 - exit.get())
            : offset.get(),
      },
      { scale: exit ? exit.get() : 1 },
    ],
  }))
  const inner = useAnimatedProps(() => ({
    opacity: clamp(entry.get()),
    transform: `translate(${width / 2 + 12} ${height / 2 + 12}) scale(${feedback.get()}) translate(${-width / 2} ${-height / 2})`,
  }))
  const blur = useAnimatedProps(() => ({
    stdDeviation: Math.max(0, 4 * (1 - entry.get())),
  }))
  const exitBlur = useAnimatedProps(() => ({
    stdDeviation: exit ? Math.max(0, 4 * (1 - exit.get())) : 0,
  }))
  const rect = useAnimatedProps(() => ({
    fill: fill.get(),
    stroke: stroke.get(),
  }))
  const text = useAnimatedProps(() => ({ fill: ink.get() }))
  const update = () =>
    feedback.set(
      withTiming(pressed.current ? 0.96 : hovered.current ? 1.05 : 1, MORPH)
    )
  return (
    <Animated.View
      style={[{ position: "absolute", left, width, height }, body]}
    >
      <Pressable
        testID={testID}
        accessibilityRole="button"
        accessibilityLabel={action.label}
        onPress={action.onPress}
        onHoverIn={() => {
          hovered.current = true
          update()
        }}
        onHoverOut={() => {
          hovered.current = false
          update()
        }}
        onPressIn={() => {
          pressed.current = true
          update()
        }}
        onPressOut={() => {
          pressed.current = false
          update()
        }}
        style={{ width, height }}
      >
        <Svg
          pointerEvents="none"
          width={width + 24}
          height={height + 24}
          style={{ position: "absolute", left: -12, top: -12 }}
          viewBox={`0 0 ${width + 24} ${height + 24}`}
        >
          <Defs>
            <Filter
              id={`${id}in`}
              x="-50%"
              y="-100%"
              width="200%"
              height="300%"
            >
              <AnimatedBlur animatedProps={blur} stdDeviation={4} />
            </Filter>
            <Filter
              id={`${id}out`}
              x="-50%"
              y="-100%"
              width="200%"
              height="300%"
            >
              <AnimatedBlur animatedProps={exitBlur} stdDeviation={0} />
            </Filter>
          </Defs>
          <G filter={exit ? `url(#${id}out)` : undefined}>
            <AnimatedG animatedProps={inner}>
              <G filter={`url(#${id}in)`}>
                <AnimatedRect
                  animatedProps={rect}
                  x={border / 2}
                  y={border / 2}
                  width={width - border}
                  height={height - border}
                  rx={(height - border) / 2}
                  strokeWidth={border}
                />
                <AnimatedText
                  animatedProps={text}
                  x={width / 2}
                  y={height / 2 + fontSize * 0.35}
                  fontSize={fontSize}
                  fontFamily={FONT}
                  fontWeight="700"
                  textAnchor="middle"
                >
                  {action.label}
                </AnimatedText>
              </G>
            </AnimatedG>
          </G>
        </Svg>
      </Pressable>
    </Animated.View>
  )
}
function ToggleIcon({
  kind,
  open,
  height,
  color,
  border,
}: {
  kind: "dots" | "close"
  open: boolean
  height: number
  color: string
  border: number
}) {
  const id = useId().replace(/:/g, "")
  const p = useSharedValue(kind === "dots" ? 1 : 0)
  const first = useRef(true)
  useLayoutEffect(() => {
    if (first.current) {
      first.current = false
      return
    }
    p.set(withTiming((kind === "close") === open ? 1 : 0, MORPH))
  }, [kind, open, p])
  const size = height === 36 ? 20 : kind === "dots" ? 28 : 24
  const group = useAnimatedProps(() => ({
    opacity: clamp(p.get()),
    transform: `translate(${height / 2 + 12} ${height / 2 + 12}) scale(${p.get()}) translate(${-size / 2} ${-size / 2})`,
  }))
  const blur = useAnimatedProps(() => ({
    stdDeviation: Math.max(0, 4 * (1 - p.get())),
  }))
  return (
    <Svg
      pointerEvents="none"
      width={height + 24}
      height={height + 24}
      viewBox={`0 0 ${height + 24} ${height + 24}`}
      style={{ position: "absolute", left: -12 - border, top: -12 - border }}
    >
      <Defs>
        <Filter id={id} x="-100%" y="-100%" width="300%" height="300%">
          <AnimatedBlur
            animatedProps={blur}
            stdDeviation={kind === "dots" ? 0 : 4}
          />
        </Filter>
      </Defs>
      <AnimatedG animatedProps={group}>
        <G filter={`url(#${id})`}>
          {kind === "dots" ? (
            <Path
              d="M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z"
              transform={`scale(${size / 24})`}
              fill="none"
              stroke={color}
              strokeWidth={2}
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          ) : (
            <Path
              d="m289.94 256 95-95A24 24 0 0 0 351 127l-95 95-95-95a24 24 0 0 0-34 34l95 95-95 95a24 24 0 1 0 34 34l95-95 95 95a24 24 0 0 0 34-34z"
              transform={`scale(${size / 512})`}
              fill={color}
            />
          )}
        </G>
      </AnimatedG>
    </Svg>
  )
}
export function InlineOverflow({
  visibleActions,
  hiddenActions,
  theme = "system",
  colors,
  size = "auto",
  maxWidth,
  radius = 32,
  onOpenChange,
  style,
  testID,
}: InlineOverflowProps) {
  const screen = useWindowDimensions()
  const scheme = useColorScheme()
  const c = {
    ...((theme === "system" ? scheme === "dark" : theme === "dark")
      ? DARK
      : LIGHT),
    ...colors,
  }
  const large = size === "large" || (size === "auto" && screen.width >= 640)
  const height = large ? 48 : 36,
    fontSize = large ? 16 : 14
  const padding = large ? 8 : 6,
    gap = large ? 8 : 6,
    px = large ? 24 : 14
  const [open, setOpen] = useState(false)
  const openRef = useRef(false)
  const [mounted, setMounted] = useState(false)
  const mountedRef = useRef(false)
  const [available, setAvailable] = useState(screen.width)
  const [border, setBorder] = useState(1)
  const rootRef = useRef<View>(null)
  const [widths, setWidths] = useState<Record<string, number>>({})
  const actions = [...visibleActions, ...hiddenActions]
  const measured = actions.every(
    (a, i) => widths[`${fontSize}-${i}-${a.label}`] !== undefined
  )
  const otherFont = fontSize === 14 ? 16 : 14
  const actionWidths = actions.map(
    (a, i) =>
      (widths[`${fontSize}-${i}-${a.label}`] ??
        ((widths[`${otherFont}-${i}-${a.label}`] ?? 0) * fontSize) /
          otherFont) +
      px * 2 +
      border * 2
  )
  const inset = padding + border
  const rootWidth = useSharedValue(0)
  const flowWidth = useSharedValue(0)
  const rootHeight = useSharedValue(height + inset * 2)
  const feedback = useSharedValue(1)
  const hover = useSharedValue(1)
  const exits = useSharedValue(1)
  const started = useRef(false)
  const previousOpen = useRef(false)
  const measure = (key: string, value: number) =>
    setWidths((old) =>
      Math.abs((old[key] ?? -1) - value) < 0.01 ? old : { ...old, [key]: value }
    )
  useLayoutEffect(() => {
    if (WEB && rootRef.current)
      setBorder(
        parseFloat(
          window.getComputedStyle(rootRef.current as unknown as Element)
            .borderTopWidth
        )
      )
  }, [])
  const count = visibleActions.length + (open ? hiddenActions.length : 0)
  const closedWidth = Math.min(
    inset * 2 +
      height +
      actionWidths.slice(0, visibleActions.length).reduce((a, b) => a + b, 0) +
      gap * visibleActions.length,
    available,
    maxWidth ?? screen.width - 24
  )
  const fullWidth =
    inset * 2 +
    height +
    actionWidths.slice(0, count).reduce((a, b) => a + b, 0) +
    gap * count
  const target = Math.min(fullWidth, available, maxWidth ?? screen.width - 24)
  useLayoutEffect(() => {
    if (!measured) return
    if (!started.current) {
      rootWidth.set(target)
      flowWidth.set(fullWidth)
      rootHeight.set(height + inset * 2)
      started.current = true
    } else {
      rootWidth.set(withTiming(target, MORPH))
      flowWidth.set(withTiming(fullWidth, MORPH))
      rootHeight.set(withTiming(height + inset * 2, MORPH))
    }
    if (previousOpen.current !== open) {
      previousOpen.current = open
      if (open) exits.set(withTiming(1, MORPH))
      else
        exits.set(
          withTiming(0, MORPH, (finished) => {
            "worklet"
            if (finished) scheduleOnRN(remove)
          })
        )
    }
  }, [
    open,
    target,
    fullWidth,
    measured,
    height,
    inset,
    rootWidth,
    flowWidth,
    rootHeight,
    exits,
  ])
  const root = useAnimatedStyle(() => ({
    width: rootWidth.get(),
    height: rootHeight.get(),
  }))
  const top = useDerivedValue(() => (rootHeight.get() - height) / 2)
  const toggle = useAnimatedStyle(() => ({
    left: flowWidth.get() - inset - height,
    top: (rootHeight.get() - height) / 2,
    opacity: hover.get(),
    transform: [{ scale: feedback.get() }],
  }))
  const remove = () => {
    if (!openRef.current) {
      mountedRef.current = false
      setMounted(false)
    }
  }
  const change = () => {
    const next = !openRef.current
    openRef.current = next
    setOpen(next)
    if (next) {
      if (!mountedRef.current) {
        exits.set(1)
        mountedRef.current = true
        setMounted(true)
      }
    }
    onOpenChange?.(next)
  }
  let offset = inset
  const renderReady = measured || started.current
  return (
    <View
      style={{
        width: "100%",
        alignItems: "center",
        justifyContent: "center",
        height: height + inset * 2,
      }}
      onLayout={(e) => setAvailable(e.nativeEvent.layout.width)}
    >
      <View
        pointerEvents="none"
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        aria-hidden
        style={{
          position: "absolute",
          width: 0,
          height: 0,
          overflow: "hidden",
          opacity: 0,
        }}
      >
        <View style={{ width: 10000, alignItems: "flex-start" }}>
          {actions.map((a, i) => (
            <Measure
              key={`${fontSize}-${i}-${a.label}`}
              label={a.label}
              fontSize={fontSize}
              onWidth={(w) => measure(`${fontSize}-${i}-${a.label}`, w)}
            />
          ))}
        </View>
      </View>
      <Animated.View
        testID={testID}
        style={[
          {
            overflow: "hidden",
            borderRadius: radius,
            opacity: renderReady ? 1 : 0,
            ...shadow,
          },
          style,
          root,
        ]}
      >
        <View
          ref={rootRef}
          pointerEvents="none"
          style={[
            StyleSheet.absoluteFill,
            {
              borderWidth: 1,
              borderRadius: radius,
              backgroundColor: c.surface,
              borderColor: c.surfaceBorder,
            },
          ]}
        />
        {renderReady &&
          actions.map((a, i) => {
            const left = offset,
              width = actionWidths[i]
            offset += width + gap
            if (i >= visibleActions.length && !mounted) return null
            return (
              <Action
                key={
                  i < visibleActions.length
                    ? `visible-${i}`
                    : `hidden-${a.label}`
                }
                action={a}
                width={width}
                height={height}
                fontSize={fontSize}
                border={border}
                left={left}
                top={top}
                colors={c}
                exit={i >= visibleActions.length ? exits : undefined}
                closing={!open}
                rootWidth={rootWidth}
                closedWidth={closedWidth}
                testID={testID ? `${testID}-action-${i}` : undefined}
              />
            )
          })}
        <Animated.View
          style={[{ position: "absolute", width: height, height }, toggle]}
        >
          <Pressable
            testID={testID ? `${testID}-toggle` : undefined}
            accessibilityRole="button"
            accessibilityLabel={open ? "Hide actions" : "Show more actions"}
            accessibilityState={{ expanded: open }}
            aria-expanded={open}
            onPress={change}
            onPressIn={() => feedback.set(withTiming(0.9, MORPH))}
            onPressOut={() => feedback.set(withTiming(1, MORPH))}
            onHoverIn={() => hover.set(0.7)}
            onHoverOut={() => hover.set(1)}
            style={{
              width: height,
              height,
              borderRadius: height / 2,
              borderWidth: 1,
              borderColor: c.actionBorder,
              backgroundColor: c.action,
            }}
          >
            <ToggleIcon
              kind="dots"
              open={open}
              height={height}
              color={c.icon}
              border={border}
            />
            <ToggleIcon
              kind="close"
              open={open}
              height={height}
              color={c.icon}
              border={border}
            />
          </Pressable>
        </Animated.View>
      </Animated.View>
    </View>
  )
}
const shadow =
  WEB ||
  Platform.OS === "ios" ||
  (Platform.OS === "android" && Number(Platform.Version) >= 28)
    ? { boxShadow: "0 1px 2px 0 rgba(0,0,0,0.05)" }
    : { elevation: 1 }

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

Back to Watermelon UI