Interaction
Inline Overflow
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.
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.