Interaction
Minimal Carousel
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.
minimal-carousel.in-progress.tsx
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import {
Platform,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
Easing,
ReduceMotion,
useAnimatedStyle,
useDerivedValue,
useFrameCallback,
useReducedMotion,
useSharedValue,
withSpring,
withTiming,
type SharedValue,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import { BlurView } from "expo-blur"
import Svg, { Circle, Path, Rect } from "react-native-svg"
const WEB = Platform.OS === "web"
const FONT = WEB ? "Arial" : Platform.OS === "ios" ? "Helvetica" : "sans-serif"
const CARD_TIME = {
duration: 600,
reduceMotion: ReduceMotion.System,
easing: (progress: number) => {
"worklet"
const t = progress * 0.6
const decay = 11.992395585723198
const angular = 8.994296689292393
return (
1 -
Math.exp(-decay * t) *
(Math.cos(angular * t) + (decay / angular) * Math.sin(angular * t))
)
},
} as const
const LAYOUT = {
duration: 450,
easing: Easing.bezier(0.4, 0, 0.1, 1),
reduceMotion: ReduceMotion.System,
} as const
const CSS = {
duration: 500,
easing: Easing.bezier(0.4, 0, 0.2, 1),
reduceMotion: ReduceMotion.System,
} as const
const HOVER = { ...CSS, duration: 150 }
const GRID_EASE = Easing.bezier(0.4, 0, 0.2, 1).factory()
const COPY_SCALE = {
stiffness: 550,
damping: 30,
mass: 1,
reduceMotion: ReduceMotion.System,
} as const
const COPY_FADE = {
duration: 300,
easing: Easing.bezier(0.25, 0.1, 0.35, 1),
reduceMotion: ReduceMotion.System,
} as const
type Box = { x: number; y: number; width: number; height: number }
type Mode = "expanded" | "compact" | "idle"
export type CarouselCard = {
id: string
title: string
value: string
color: string
icon: React.ComponentType<{ size: number; color: string }>
}
export type MinimalCarouselProps = {
cards: CarouselCard[]
onCopyClick?: (card: CarouselCard) => void
onCustomizeClick?: (card: CarouselCard) => void
defaultActiveId?: string | null
textColor?: string
radius?: number
expandedRadius?: number
gap?: number
expandedHeight?: number
tileHeight?: number
compactHeight?: number
blurIntensity?: number
style?: StyleProp<ViewStyle>
testID?: string
}
function cardShadow(expanded: boolean): ViewStyle {
if (Platform.OS === "android" && Number(Platform.Version) < 28)
return { elevation: expanded ? 8 : 4 }
return {
boxShadow: expanded
? [
{
offsetX: 0,
offsetY: 25,
blurRadius: 50,
spreadDistance: -12,
color: "rgba(0,0,0,.25)",
},
]
: [
{
offsetX: 0,
offsetY: 10,
blurRadius: 15,
spreadDistance: -3,
color: "rgba(0,0,0,.1)",
},
{
offsetX: 0,
offsetY: 4,
blurRadius: 6,
spreadDistance: -4,
color: "rgba(0,0,0,.1)",
},
],
}
}
function More({ size = 16, color }: { size?: number; color: string }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" accessible={false}>
<Circle
cx={12}
cy={12}
r={1}
fill="none"
stroke={color}
strokeWidth={2}
/>
<Circle
cx={19}
cy={12}
r={1}
fill="none"
stroke={color}
strokeWidth={2}
/>
<Circle cx={5} cy={12} r={1} fill="none" stroke={color} strokeWidth={2} />
</Svg>
)
}
function CopyIcon({ color }: { color: string }) {
return (
<Svg width={16} height={16} viewBox="0 0 24 24" accessible={false}>
<Rect
width={14}
height={14}
x={8}
y={8}
rx={2}
fill="none"
stroke={color}
strokeWidth={2}
/>
<Path
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
/>
</Svg>
)
}
function Action({
copy,
tablet,
color,
blurIntensity,
onPress,
testID,
}: {
copy?: boolean
tablet: boolean
color: string
blurIntensity: number
onPress?: () => void
testID?: string
}) {
const opacity = useSharedValue(copy ? 0 : 1),
scale = useSharedValue(copy ? 0.8 : 1),
background = useSharedValue(
copy ? "rgba(255,255,255,.1)" : "rgba(255,255,255,.3)"
)
useEffect(() => {
if (!copy) return
opacity.set(withTiming(1, COPY_FADE))
scale.set(withSpring(1, COPY_SCALE))
}, [copy, opacity, scale])
const style = useAnimatedStyle(() => ({
opacity: opacity.get(),
backgroundColor: background.get(),
transform: [{ scale: scale.get() }],
}))
const webBlur = WEB ? ({ backdropFilter: "blur(12px)" } as ViewStyle) : {}
return (
<Animated.View
style={[{ borderRadius: 999, overflow: "hidden" }, webBlur, style]}
>
{!WEB && (
<BlurView
intensity={blurIntensity}
experimentalBlurMethod="dimezisBlurView"
blurReductionFactor={5}
tint="default"
style={StyleSheet.absoluteFill}
/>
)}
<Pressable
accessibilityRole="button"
accessibilityLabel={copy ? "Copy address" : "Edit card"}
onPress={onPress}
onHoverIn={() =>
background.set(
withTiming(
copy ? "rgba(255,255,255,.2)" : "rgba(255,255,255,.4)",
HOVER
)
)
}
onHoverOut={() =>
background.set(
withTiming(
copy ? "rgba(255,255,255,.1)" : "rgba(255,255,255,.3)",
HOVER
)
)
}
testID={testID}
style={{
paddingHorizontal: tablet ? 16 : 12,
paddingVertical: copy ? (tablet ? 8 : 6) : tablet ? 6 : 4,
flexDirection: "row",
alignItems: "center",
gap: 6,
}}
>
<Text
numberOfLines={1}
style={{
fontFamily: FONT,
fontSize: tablet ? 16 : copy ? 12 : 14,
lineHeight: tablet ? 24 : copy ? 16 : 20,
fontWeight: "700",
color,
}}
>
{copy ? "Copy" : "Edit"}
</Text>
{copy && <CopyIcon color={color} />}
</Pressable>
</Animated.View>
)
}
type Appearance = { mode: Mode; width: number; height: number }
type ContentProps = {
appearance: Appearance
card: CarouselCard
tablet: boolean
color: string
blurIntensity: number
onCopy?: (card: CarouselCard) => void
onEdit?: (card: CarouselCard) => void
testID?: string
width: SharedValue<number>
height: SharedValue<number>
progress: SharedValue<number>
outgoing?: boolean
}
function Content({
appearance,
card,
tablet,
color,
blurIntensity,
onCopy,
onEdit,
testID,
width,
height,
progress,
outgoing,
}: ContentProps) {
const { mode } = appearance
const expanded = mode === "expanded",
compact = mode === "compact"
const padding = expanded ? (tablet ? 20 : 16) : tablet ? 16 : 12
const style = useAnimatedStyle(() => {
const p = Math.max(0, Math.min(1, progress.get()))
const opacity = outgoing
? 1 - Math.max(0, Math.min(1, (p - 0.5) / 0.45))
: Math.sqrt(1 - Math.pow(1 - Math.min(1, p / 0.5), 2))
return {
opacity,
transform: [
{ scaleX: width.get() / appearance.width },
{ scaleY: height.get() / appearance.height },
],
}
})
const font = expanded
? tablet
? 24
: 20
: compact
? tablet
? 12
: 10
: tablet
? 16
: 14
const valueFont = expanded ? (tablet ? 20 : 18) : font
const Icon = card.icon
return (
<Animated.View
pointerEvents={outgoing ? "none" : "box-none"}
aria-hidden={outgoing || undefined}
accessibilityElementsHidden={outgoing}
importantForAccessibility={outgoing ? "no-hide-descendants" : "auto"}
style={[
{
position: "absolute",
left: 0,
top: 0,
width: appearance.width,
height: appearance.height,
padding,
justifyContent: "space-between",
transformOrigin: "top left",
},
style,
]}
>
<View
pointerEvents="box-none"
style={{
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: expanded ? 8 : 0,
}}
>
{expanded ? (
<View
style={{
width: tablet ? 44 : 40,
height: tablet ? 44 : 40,
alignItems: "center",
justifyContent: "center",
}}
>
<Icon size={tablet ? 44 : 38} color={color} />
</View>
) : (
<Icon size={compact ? 20 : 28} color={color} />
)}
{expanded ? (
<Action
copy
tablet={tablet}
color={color}
blurIntensity={blurIntensity}
onPress={() => onCopy?.(card)}
testID={testID ? `${testID}-copy` : undefined}
/>
) : (
<View
style={{
borderRadius: 999,
backgroundColor: "rgba(255,255,255,.1)",
padding: tablet ? 6 : 4,
}}
>
<More color={color} />
</View>
)}
</View>
<View
pointerEvents="box-none"
style={{
flexDirection: "row",
alignItems: "flex-end",
justifyContent: "space-between",
marginTop: expanded ? 16 : 4,
}}
>
<View
style={{
flexShrink: 1,
minWidth: 0,
overflow: "hidden",
marginRight: expanded ? 8 : 0,
}}
>
<Text
numberOfLines={1}
style={{
fontFamily: FONT,
fontSize: font,
lineHeight: font * 1.25,
fontWeight: expanded ? "600" : "500",
color,
opacity: 0.9,
}}
>
{card.title}
</Text>
<Text
numberOfLines={1}
style={{
fontFamily: FONT,
fontSize: valueFont,
lineHeight: expanded
? tablet
? 28
: 28
: compact
? tablet
? 16
: 15
: tablet
? 24
: 20,
fontWeight: "600",
color,
opacity: 0.6,
letterSpacing: expanded ? -valueFont * 0.025 : 0,
}}
>
{card.value}
</Text>
</View>
{expanded && (
<Action
tablet={tablet}
color={color}
blurIntensity={blurIntensity}
onPress={() => onEdit?.(card)}
testID={testID ? `${testID}-edit` : undefined}
/>
)}
</View>
</Animated.View>
)
}
type GridProjection = {
fromHeight: number
toHeight: number
fromY: number
toY: number
fromScene: number
toScene: number
}
function Tile({
card,
box,
mode,
tablet,
color,
radius,
blurIntensity,
selected,
onSelect,
onCopy,
onEdit,
testID,
grid,
layoutProgress,
projection,
sceneHeight,
gridOrigin,
}: {
card: CarouselCard
box: Box
mode: Mode
tablet: boolean
color: string
radius: number
blurIntensity: number
selected: boolean
onSelect: () => void
onCopy?: (card: CarouselCard) => void
onEdit?: (card: CarouselCard) => void
testID?: string
grid: SharedValue<{ scale: number; translate: number }>
layoutProgress: SharedValue<number>
projection: SharedValue<GridProjection>
sceneHeight: SharedValue<number>
gridOrigin: number
}) {
const x = useSharedValue(box.x),
y = useSharedValue(box.y - (mode === "expanded" ? 0 : gridOrigin)),
width = useSharedValue(box.width),
height = useSharedValue(box.height),
round = useSharedValue(radius)
const progress = useSharedValue(1)
const generation = useRef(0)
const reduced = useReducedMotion()
const previous = useRef<Appearance>({
mode,
width: box.width,
height: box.height,
})
const [outgoing, setOutgoing] = useState<Appearance | null>(null)
const oldMode = useRef(mode)
const previousGridOrigin = useRef(gridOrigin)
const clear = useCallback((id: number) => {
if (generation.current === id) setOutgoing(null)
}, [])
useLayoutEffect(() => {
if (previous.current.mode !== mode) {
setOutgoing(previous.current)
const id = ++generation.current
progress.set(0)
progress.set(
withTiming(1, CARD_TIME, (finished) => {
if (finished) scheduleOnRN(clear, id)
})
)
}
previous.current = { mode, width: box.width, height: box.height }
}, [mode, box.width, box.height, progress, clear])
useEffect(() => {
x.set(withTiming(box.x, CARD_TIME))
if (oldMode.current !== "expanded" && mode === "expanded") {
y.set(
y.get() +
previousGridOrigin.current +
(oldMode.current === "idle" ? gridOrigin / 2 : 0)
)
} else if (oldMode.current === "expanded" && mode !== "expanded")
y.set(y.get() - gridOrigin)
oldMode.current = mode
previousGridOrigin.current = gridOrigin
y.set(withTiming(box.y - (mode === "expanded" ? 0 : gridOrigin), CARD_TIME))
width.set(withTiming(box.width, CARD_TIME))
height.set(withTiming(box.height, CARD_TIME))
round.set(withTiming(radius, CARD_TIME))
}, [
box.x,
box.y,
box.width,
box.height,
radius,
mode,
projection,
gridOrigin,
x,
y,
width,
height,
round,
])
const projectedHeight = useDerivedValue(() => {
const m = projection.get(),
q = layoutProgress.get()
const rawScale =
m.toHeight > 0 ? 1 + (m.fromHeight / m.toHeight - 1) * (1 - q) : 1
const parentScale = m.toScene > 0 ? sceneHeight.get() / m.toScene : 1
return mode === "expanded" || reduced
? height.get()
: Math.max(1, (height.get() / rawScale) * grid.get().scale * parentScale)
})
const style = useAnimatedStyle(() => {
const m = projection.get(),
q = layoutProgress.get()
const rawScale =
m.toHeight > 0 ? 1 + (m.fromHeight / m.toHeight - 1) * (1 - q) : 1
const parentScale = m.toScene > 0 ? sceneHeight.get() / m.toScene : 1
const localY = y.get() / rawScale
const top =
mode === "expanded"
? y.get()
: reduced
? y.get() + gridOrigin
: (m.toY + grid.get().translate + localY * grid.get().scale) *
parentScale
return {
left: x.get(),
top,
width: width.get(),
height: projectedHeight.get(),
borderRadius: Math.max(0, round.get()),
}
})
const contentProps = {
card,
tablet,
color,
blurIntensity,
onCopy,
onEdit,
testID,
width,
height: projectedHeight,
progress,
}
const clipStyle = useAnimatedStyle(() => ({
borderRadius: Math.max(0, round.get()),
}))
return (
<Animated.View
testID={testID}
style={[
{
position: "absolute",
backgroundColor: card.color,
zIndex: selected ? 2 : 1,
},
cardShadow(selected),
style,
]}
>
<Animated.View style={[{ flex: 1, overflow: "hidden" }, clipStyle]}>
<Pressable
accessible={!selected}
accessibilityRole={selected ? undefined : "button"}
accessibilityLabel={`${card.title}, ${card.value}`}
onPress={selected ? undefined : onSelect}
testID={testID ? `${testID}-select` : undefined}
style={StyleSheet.absoluteFill}
>
{outgoing && (
<Content {...contentProps} appearance={outgoing} outgoing />
)}
<Content
{...contentProps}
appearance={{ mode, width: box.width, height: box.height }}
/>
</Pressable>
</Animated.View>
</Animated.View>
)
}
export function MinimalCarousel({
cards,
onCopyClick,
onCustomizeClick,
defaultActiveId = null,
textColor = "#fff",
radius,
expandedRadius,
gap,
expandedHeight,
tileHeight,
compactHeight,
blurIntensity = 60,
style,
testID,
}: MinimalCarouselProps) {
const { width: screenWidth } = useWindowDimensions()
const tablet = screenWidth >= 640
const [measuredWidth, setMeasuredWidth] = useState(0),
[activeId, setActiveId] = useState<string | null>(defaultActiveId)
const active = cards.find((card) => card.id === activeId)
const expanded = Boolean(active)
const width = Math.max(1, measuredWidth)
const gutter = gap ?? (tablet ? 12 : 8),
bigHeight = expandedHeight ?? (tablet ? 192 : 170),
smallHeight = tileHeight ?? (tablet ? 128 : 112),
shrunkHeight = compactHeight ?? (tablet ? 112 : 96)
const secondary = expanded
? cards.filter((card) => card.id !== activeId)
: cards
const columns = expanded ? 3 : 2,
itemWidth = (width - (columns - 1) * gutter) / columns
const rows = Math.ceil(secondary.length / columns)
const gridHeight = rows
? rows * (expanded ? shrunkHeight : smallHeight) + (rows - 1) * gutter
: 0
const targetHeight = expanded
? bigHeight + (rows ? 12 + gridHeight : 0)
: gridHeight
const sceneHeight = useSharedValue(targetHeight),
layoutProgress = useSharedValue(1)
const grid = useSharedValue({ scale: 1, translate: 0 })
const projection = useSharedValue<GridProjection>({
fromHeight: gridHeight,
toHeight: gridHeight,
fromY: expanded ? bigHeight + 12 : 0,
toY: expanded ? bigHeight + 12 : 0,
fromScene: targetHeight,
toScene: targetHeight,
})
const old = useRef({
height: gridHeight,
y: expanded ? bigHeight + 12 : 0,
scene: targetHeight,
})
const cssState = useSharedValue({
lastScale: 1,
lastTranslate: 0,
fromScale: 1,
fromTranslate: 0,
changedAt: 0,
})
const reduced = useReducedMotion()
const gridFrameRef = useRef<{ setActive: (active: boolean) => void } | null>(
null
)
const gridGenerationRef = useRef(0)
const gridGeneration = useSharedValue(0)
const queuedStopGeneration = useSharedValue(-1)
const stopGrid = useCallback((generation: number) => {
if (generation === gridGenerationRef.current)
gridFrameRef.current?.setActive(false)
}, [])
const gridFrame = useFrameCallback((frame) => {
const m = projection.get(),
q = layoutProgress.get()
const rawScale =
m.toHeight > 0 ? 1 + (m.fromHeight / m.toHeight - 1) * (1 - q) : 1
const rawTranslate = (m.fromY - m.toY) * (1 - q)
const state = cssState.get(),
current = grid.get()
const changed =
Math.abs(rawScale - state.lastScale) > 0.000001 ||
Math.abs(rawTranslate - state.lastTranslate) > 0.000001
const p = reduced
? 1
: GRID_EASE(
Math.max(0, Math.min(1, (frame.timestamp - state.changedAt) / 500))
)
const next = {
scale: state.fromScale + (state.lastScale - state.fromScale) * p,
translate:
state.fromTranslate + (state.lastTranslate - state.fromTranslate) * p,
}
grid.set(reduced ? { scale: 1, translate: 0 } : next)
if (changed)
cssState.set({
lastScale: rawScale,
lastTranslate: rawTranslate,
fromScale: next.scale,
fromTranslate: next.translate,
changedAt: frame.timestamp,
})
if (
!changed &&
p === 1 &&
current.scale === next.scale &&
current.translate === next.translate &&
queuedStopGeneration.get() !== gridGeneration.get()
) {
const generation = gridGeneration.get()
queuedStopGeneration.set(generation)
scheduleOnRN(stopGrid, generation)
}
}, false)
gridFrameRef.current = gridFrame
useEffect(() => {
const previous = old.current,
y = expanded ? bigHeight + 12 : 0
if (previous.height !== gridHeight || previous.y !== y) {
gridGeneration.set(++gridGenerationRef.current)
projection.set({
fromHeight: previous.height,
toHeight: gridHeight,
fromY: previous.y,
toY: y,
fromScene: previous.scene,
toScene: targetHeight,
})
layoutProgress.set(0)
layoutProgress.set(withTiming(1, LAYOUT))
gridFrame.setActive(true)
}
sceneHeight.set(withTiming(targetHeight, LAYOUT))
old.current = { height: gridHeight, y, scene: targetHeight }
}, [
targetHeight,
expanded,
gridHeight,
bigHeight,
sceneHeight,
layoutProgress,
projection,
gridFrame,
gridGeneration,
])
const scene = useAnimatedStyle(() => ({
height: Math.max(0, sceneHeight.get()),
}))
return (
<View
style={[
{
width: "100%",
paddingHorizontal: tablet ? 16 : 12,
alignItems: "center",
},
style,
]}
testID={testID}
>
<Pressable
accessibilityLabel="Collapse selected card"
accessibilityRole="button"
onPress={() => setActiveId(null)}
testID={testID ? `${testID}-background` : undefined}
style={{ position: "absolute", inset: 0 }}
/>
<Animated.View
pointerEvents="box-none"
onLayout={({ nativeEvent }) =>
setMeasuredWidth(nativeEvent.layout.width)
}
style={[{ width: "100%", maxWidth: 420 }, scene]}
>
{measuredWidth > 0 &&
cards.map((card) => {
const selected = card.id === active?.id,
index = secondary.findIndex((item) => item.id === card.id)
const box = selected
? { x: 0, y: 0, width, height: bigHeight }
: {
x: (index % columns) * (itemWidth + gutter),
y:
(expanded ? bigHeight + 12 : 0) +
Math.floor(index / columns) *
((expanded ? shrunkHeight : smallHeight) + gutter),
width: itemWidth,
height: expanded ? shrunkHeight : smallHeight,
}
return (
<Tile
key={card.id}
card={card}
box={box}
mode={selected ? "expanded" : expanded ? "compact" : "idle"}
tablet={tablet}
color={textColor}
radius={
selected
? (expandedRadius ?? (tablet ? 32 : 28))
: (radius ?? (tablet ? 28 : 22))
}
blurIntensity={blurIntensity}
selected={selected}
onSelect={() => setActiveId(card.id)}
onCopy={onCopyClick}
onEdit={onCustomizeClick}
testID={testID ? `${testID}-${card.id}` : undefined}
grid={grid}
layoutProgress={layoutProgress}
projection={projection}
sceneHeight={sceneHeight}
gridOrigin={expanded ? bigHeight + 12 : 0}
/>
)
})}
</Animated.View>
</View>
)
}
Give the gestures and motion a run on a real Android phone.