Interaction
Expand Details
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.
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.