Skip to content
pixelfor Expo
Ctrl K

Interaction

Expand Details

An expressive interaction rebuilt for touch

Try it on your phone

Expand Details

Scan to open Expand Details 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.

expand-details.in-progress.tsx

import React, { useEffect, useId, useState } from "react"
import {
  Platform,
  Pressable,
  Text,
  View,
  useColorScheme,
  useWindowDimensions,
  type TextLayoutLine,
  type StyleProp,
  type ViewStyle,
} from "react-native"
import Animated, {
  Easing,
  defineAnimation,
  useAnimatedProps,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
  type AnimationObject,
} from "react-native-reanimated"
import Svg, {
  Defs,
  Filter,
  FeGaussianBlur,
  G,
  Path,
  Text as SvgText,
} from "react-native-svg"
export interface ExpandDetailsProps {
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  title?: string
  details?: { label: string; value: string }[]
  expandedWidth?: number
  collapsedWidth?: number
  expandedRadius?: number
  collapsedRadius?: number
  theme?: "light" | "dark" | "system"
  fontFamily?: string
  colors?: Partial<{
    surface: string
    text: string
    muted: string
    hover: string
  }>
  style?: StyleProp<ViewStyle>
  testID?: string
}
const DEFAULT_DETAILS = [
  { label: "Model", value: "GPT 5.5 Codex" },
  { label: "Tokens", value: "3.4K" },
  { label: "Cost", value: "$0.27" },
  { label: "Latency", value: "1.4s" },
  { label: "Temperature", value: "0.7" },
]
const SPRING = { stiffness: 200, damping: 22, mass: 1.2 }
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
}
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),
  AP = Animated.createAnimatedComponent(Path)
function critical(p: number) {
  "worklet"
  const t = 9.233413476 * p
  return p >= 1 ? 1 : 1 - Math.exp(-t) * (1 + t)
}
export function ExpandDetails({
  defaultOpen = true,
  onOpenChange,
  title = "Details",
  details = DEFAULT_DETAILS,
  expandedWidth = 320,
  collapsedWidth = 120,
  expandedRadius = 20,
  collapsedRadius = 24,
  theme = "system",
  fontFamily = Platform.OS === "web" ? "Arial" : undefined,
  colors: overrides,
  style,
  testID,
}: ExpandDetailsProps) {
  const [open, setOpen] = useState(defaultOpen),
    [hovered, setHovered] = useState(false)
  const scheme = useColorScheme(),
    dark = theme === "dark" || (theme === "system" && scheme === "dark")
  const colors = {
    surface: dark ? "#18181b" : "#f4f4f5",
    text: dark ? "#f4f4f5" : "#18181b",
    muted: dark ? "#a1a1aa" : "#71717a",
    hover: dark ? "#f4f4f5" : "#18181b",
    ...overrides,
  }
  const id = useId().replace(/\W/g, "")
  const arrowColor = useSharedValue(colors.muted)
  useEffect(() => {
    arrowColor.set(
      withTiming(hovered ? colors.hover : colors.muted, {
        duration: 150,
        easing: Easing.bezier(0.4, 0, 0.2, 1),
      })
    )
  }, [hovered, colors.hover, colors.muted])
  const arrowStroke = useAnimatedProps(() => ({ stroke: arrowColor.get() }))
  const { fontScale } = useWindowDimensions()
  const [rowY, setRowY] = useState<Record<number, number>>({})
  const [textY, setTextY] = useState<Record<string, number>>({})
  const [lines, setLines] = useState<Record<string, TextLayoutLine[]>>({})
  function recordLines(key: string, next: TextLayoutLine[]) {
    setLines((previous) =>
      JSON.stringify(previous[key]) === JSON.stringify(next)
        ? previous
        : { ...previous, [key]: next }
    )
  }
  const rowCount = details.length
    ? 1 + Math.ceil(Math.max(0, details.length - 1) / 2)
    : 0
  const [measuredBodyHeight, setMeasuredBodyHeight] = useState<number | null>(
    null
  )
  const bodyHeight =
    measuredBodyHeight ??
    (rowCount ? 12 + rowCount * 52 + (rowCount - 1) * 20 : 0)
  const width = useSharedValue(defaultOpen ? expandedWidth : collapsedWidth),
    height = useSharedValue(44 + (defaultOpen ? bodyHeight : 0)),
    radius = useSharedValue(defaultOpen ? expandedRadius : collapsedRadius)
  const presence = useSharedValue(defaultOpen ? 1 : 0),
    y = useSharedValue(defaultOpen ? 0 : 40),
    rotation = useSharedValue(defaultOpen ? 0 : -90)
  useEffect(() => {
    width.set(
      motionDelay(
        open ? 0 : 300,
        withSpring(open ? expandedWidth : collapsedWidth, SPRING)
      )
    )
  }, [open, expandedWidth, collapsedWidth])
  useEffect(() => {
    height.set(
      motionDelay(
        open ? 250 : 0,
        withSpring(44 + (open ? bodyHeight : 0), SPRING)
      )
    )
  }, [open, bodyHeight])
  useEffect(() => {
    radius.set(withSpring(open ? expandedRadius : collapsedRadius, SPRING))
  }, [open, expandedRadius, collapsedRadius])
  useEffect(() => {
    rotation.set(
      motionDelay(
        300,
        withTiming(open ? 0 : -90, {
          duration: 200,
          easing: Easing.bezier(0, 0, 0.58, 1),
        })
      )
    )
  }, [open])
  useEffect(() => {
    if (open && presence.get() === 0) y.set(40)
    presence.set(
      motionDelay(
        open ? 300 : 0,
        withTiming(open ? 1 : 0, { duration: 400, easing: critical })
      )
    )
    y.set(
      motionDelay(
        open ? 300 : 0,
        withTiming(open ? 0 : 16, { duration: 400, easing: critical })
      )
    )
  }, [open])
  const box = useAnimatedStyle(() => ({
    width: width.get(),
    height: height.get(),
    borderRadius: radius.get(),
  }))
  const arrow = useAnimatedStyle(() => ({
    transform: [{ rotate: `${rotation.get()}deg` }],
  }))
  const body = useAnimatedProps(() => ({
    opacity: presence.get(),
    transform: `translate(0 ${y.get()})`,
  }))
  const blur = useAnimatedProps(() => ({
    stdDeviation: 8 * (1 - presence.get()),
  }))
  function toggle() {
    const next = !open
    setOpen(next)
    onOpenChange?.(next)
  }
  return (
    <Animated.View
      testID={testID}
      style={[
        {
          alignSelf: "center",
          overflow: "hidden",
          backgroundColor: colors.surface,
        },
        box,
        style,
      ]}
    >
      <View
        pointerEvents="none"
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        style={{
          position: "absolute",
          left: 16,
          top: 36,
          width: expandedWidth,
          opacity: 0,
        }}
        onLayout={(event) =>
          setMeasuredBodyHeight(event.nativeEvent.layout.height)
        }
      >
        <View
          style={{
            marginTop: details.length ? 12 : 0,
            marginLeft: 24,
            gap: 20,
          }}
        >
          {Array.from({ length: rowCount }, (_, row) => {
            const items =
              row === 0
                ? details.slice(0, 1)
                : details.slice(1 + (row - 1) * 2, 3 + (row - 1) * 2)
            return (
              <View
                key={row}
                style={{ flexDirection: "row", gap: 16 }}
                onLayout={(event) => {
                  const y = event.nativeEvent.layout.y
                  setRowY((previous) =>
                    previous[row] === y ? previous : { ...previous, [row]: y }
                  )
                }}
              >
                {items.map((item, index) => (
                  <View key={index} style={{ flex: 1 }}>
                    <Text
                      onLayout={(event) => {
                        const key = `${row}-${index}-label`,
                          y = event.nativeEvent.layout.y
                        setTextY((previous) =>
                          previous[key] === y
                            ? previous
                            : { ...previous, [key]: y }
                        )
                      }}
                      onTextLayout={(event) =>
                        recordLines(
                          `${row}-${index}-label`,
                          event.nativeEvent.lines
                        )
                      }
                      style={{
                        fontFamily,
                        fontSize: 14,
                        lineHeight: 20,
                        fontWeight: "500",
                      }}
                    >
                      {item.label}
                    </Text>
                    <Text
                      onLayout={(event) => {
                        const key = `${row}-${index}-value`,
                          y = event.nativeEvent.layout.y
                        setTextY((previous) =>
                          previous[key] === y
                            ? previous
                            : { ...previous, [key]: y }
                        )
                      }}
                      onTextLayout={(event) =>
                        recordLines(
                          `${row}-${index}-value`,
                          event.nativeEvent.lines
                        )
                      }
                      style={{
                        fontFamily,
                        fontSize: 18,
                        lineHeight: 28,
                        letterSpacing: -0.45,
                        marginTop: 4,
                      }}
                    >
                      {item.value}
                    </Text>
                  </View>
                ))}
                {row > 0 && items.length === 1 && <View style={{ flex: 1 }} />}
              </View>
            )
          })}
        </View>
      </View>
      <Pressable
        accessibilityRole="button"
        accessibilityLabel={title}
        accessibilityState={{ expanded: open }}
        onPress={toggle}
        onHoverIn={() => setHovered(true)}
        onHoverOut={() => setHovered(false)}
        style={{
          position: "absolute",
          left: 16,
          right: 16,
          top: 8,
          height: 28,
          flexDirection: "row",
          alignItems: "center",
          gap: 4,
        }}
      >
        <Animated.View style={arrow}>
          <Svg
            width={20}
            height={20}
            viewBox="0 0 24 24"
            fill="none"
            strokeWidth={2}
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <AP animatedProps={arrowStroke} d="m6 9 6 6 6-6" />
          </Svg>
        </Animated.View>
        <Text
          numberOfLines={1}
          style={{
            fontFamily,
            fontSize: 18,
            lineHeight: 28,
            fontWeight: "500",
            letterSpacing: -0.45,
            color: colors.text,
          }}
        >
          {title}
        </Text>
      </Pressable>
      <View
        pointerEvents="none"
        accessibilityElementsHidden={!open}
        importantForAccessibility={open ? "auto" : "no-hide-descendants"}
        style={{
          position: "absolute",
          left: 16,
          top: 36,
          width: expandedWidth,
          height: bodyHeight,
        }}
      >
        <Svg
          width={expandedWidth}
          height={bodyHeight}
          style={{ overflow: "visible" }}
        >
          <Defs>
            <Filter id={id} x="-10%" y="-20%" width="120%" height="140%">
              <AB animatedProps={blur} />
            </Filter>
          </Defs>
          <AG animatedProps={body}>
            <G filter={`url(#${id})`}>
              {details.map((item, i) => {
                const row = i === 0 ? 0 : 1 + Math.floor((i - 1) / 2)
                const x =
                  24 +
                  (i > 0 && i % 2 === 0 ? (expandedWidth - 24 + 16) / 2 : 0)
                const top = 12 + (rowY[row] ?? row * 72)
                const column = i === 0 ? 0 : (i - 1) % 2
                function textLines(kind: "label" | "value") {
                  const key = `${row}-${column}-${kind}`
                  const measured = lines[key]
                  const size = kind === "label" ? 14 : 18
                  return measured?.length ? (
                    measured.map((line, index) => (
                      <SvgText
                        key={`${kind}-${index}`}
                        x={x + line.x}
                        y={
                          top +
                          (textY[key] ?? (kind === "label" ? 0 : 24)) +
                          line.y +
                          line.ascender
                        }
                        fontFamily={fontFamily}
                        fontSize={size * fontScale}
                        fontWeight={kind === "label" ? "500" : undefined}
                        letterSpacing={
                          kind === "value" ? -0.45 * fontScale : undefined
                        }
                        fill={kind === "label" ? colors.muted : colors.text}
                      >
                        {line.text}
                      </SvgText>
                    ))
                  ) : (
                    <SvgText
                      x={x}
                      y={top + (kind === "label" ? 15 : 46)}
                      fontFamily={fontFamily}
                      fontSize={size * fontScale}
                      fontWeight={kind === "label" ? "500" : undefined}
                      fill={kind === "label" ? colors.muted : colors.text}
                    >
                      {item[kind]}
                    </SvgText>
                  )
                }
                return (
                  <G key={`${i}-${item.label}`}>
                    {textLines("label")}
                    {textLines("value")}
                  </G>
                )
              })}
            </G>
          </AG>
        </Svg>
      </View>
    </Animated.View>
  )
}
export default ExpandDetails

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

Back to Watermelon UI