Interaction
Journal Navigation
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.
journal-navigation.in-progress.tsx
import React, {
useCallback,
useEffect,
useId,
useRef,
useState,
type ReactNode,
} from "react"
import {
Platform,
Pressable,
Text,
View,
useColorScheme,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
Easing,
cancelAnimation,
useAnimatedProps,
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
withTiming,
} from "react-native-reanimated"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import { scheduleOnRN } from "react-native-worklets"
import Svg, {
Defs,
Filter,
FeGaussianBlur,
G,
Path,
Rect,
Stop,
LinearGradient as SvgGradient,
Text as SvgText,
} from "react-native-svg"
import { BlurView } from "expo-blur"
function DatePart({
text,
direction,
fontFamily,
color,
}: {
text: string
direction: number
fontFamily?: string
color: string
}) {
const [layers, setLayers] = useState([{ text, id: 0, exit: false }])
const next = useRef(0),
previous = useRef(text)
useEffect(() => {
if (previous.current === text) return
previous.current = text
setLayers((rows) => {
const returning = rows.some((row) => row.text === text)
const layers = rows.map((row) => ({ ...row, exit: row.text !== text }))
return returning
? layers
: [...layers, { text, id: ++next.current, exit: false }]
})
}, [text])
const remove = useCallback(
(id: number) =>
setLayers((rows) => rows.filter((row) => row.id !== id || !row.exit)),
[]
)
return (
<View style={{ overflow: "hidden", height: 26 }}>
<Text
style={{
opacity: 0,
fontFamily,
fontSize: 18,
fontWeight: "500",
letterSpacing: -0.45,
}}
>
{text}
</Text>
{layers.map((layer) => (
<DateLayer
key={layer.id}
{...layer}
direction={direction}
fontFamily={fontFamily}
color={color}
remove={remove}
/>
))}
</View>
)
}
function DateLayer({
id,
text,
exit,
direction,
fontFamily,
color,
remove,
}: {
id: number
text: string
exit: boolean
direction: number
fontFamily?: string
color: string
remove: (id: number) => void
}) {
const progress = useSharedValue(0),
y = useSharedValue(direction > 0 ? 3 : -3)
useEffect(() => {
const timing = { duration: 300, easing: Easing.bezier(0, 0, 0.58, 1) }
progress.set(
withTiming(exit ? 0 : 1, timing, (finished) => {
if (finished && exit) scheduleOnRN(remove, id)
})
)
y.set(withTiming(exit ? (direction > 0 ? 3 : -3) : 0, timing))
}, [exit])
const motion = useAnimatedStyle(() => ({
opacity: progress.get(),
transform: [{ translateY: y.get() }],
}))
return (
<Animated.Text
style={[
{
position: "absolute",
left: 0,
top: 0,
fontFamily,
fontSize: 18,
fontWeight: "500",
letterSpacing: -0.45,
color,
},
motion,
]}
>
{text}
</Animated.Text>
)
}
export interface JournalEntry {
id: string | number
day: number
month: string
year?: number
content: ReactNode
}
export interface JournalNavigationProps {
entries: JournalEntry[]
initialIndex?: number
onEntryChange?: (entry: JournalEntry) => void
theme?: "light" | "dark" | "system"
width?: number
height?: number
radius?: number
fontFamily?: string
colors?: Partial<{
surface: string
border: string
rail: string
active: string
day: string
muted: string
dayHoverBackground: string
dayHoverText: string
text: string
date: string
}>
style?: StyleProp<ViewStyle>
testID?: string
}
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: (k: string, v: 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)
const frequency = Math.log(0.9 / Math.sqrt(1 - 0.9 * 0.9) / 0.001) / (0.9 * 0.3)
function letterEase(p: number) {
"worklet"
const t = p * 0.3,
a = frequency * Math.sqrt(1 - 0.9 * 0.9),
d = frequency * 0.9
return p >= 1
? 1
: 1 - Math.exp(-d * t) * (Math.cos(a * t) + (d / a) * Math.sin(a * t))
}
function Letter({
char,
x,
y,
index,
exit,
direction,
color,
fontFamily,
}: {
char: string
x: number
y: number
index: number
exit: boolean
direction: number
color: string
fontFamily?: string
}) {
const id = useId().replace(/\W/g, ""),
progress = useSharedValue(0)
useEffect(() => {
progress.set(
withDelay(
index * 10,
withTiming(exit ? 0 : 1, { duration: 300, easing: letterEase })
)
)
return () => cancelAnimation(progress)
}, [exit])
const blur = useAnimatedProps(() => ({
stdDeviation: Math.max(0, 2 * (1 - progress.get())),
})),
motion = useAnimatedProps(() => ({
opacity: Math.max(0, Math.min(1, progress.get())),
transform: `translate(0 ${(direction > 0 ? 3 : -3) * (1 - progress.get())})`,
}))
return (
<>
<Defs>
<Filter id={id} x="-100%" y="-50%" width="300%" height="200%">
<AB animatedProps={blur} />
</Filter>
</Defs>
<AG animatedProps={motion} filter={`url(#${id})`}>
<SvgText
x={x}
y={y}
fontSize={18}
fontWeight="700"
letterSpacing={-0.45}
fill={color}
fontFamily={fontFamily}
>
{char}
</SvgText>
</AG>
</>
)
}
function Day({
day,
active,
choose,
colors,
fontFamily,
}: {
day: number
active: boolean
choose: () => void
colors: {
active: string
day: string
muted: string
dayHoverBackground: string
dayHoverText: string
}
fontFamily?: string
}) {
const [hovered, setHovered] = useState(false)
const scale = useSharedValue(active ? 1.2 : 1)
const background = useSharedValue(active ? colors.active : "transparent")
const foreground = useSharedValue(active ? colors.day : colors.muted)
useEffect(() => {
const transition = {
duration: 150,
easing: Easing.bezier(0.4, 0, 0.2, 1),
}
background.set(
withTiming(
active
? colors.active
: hovered
? colors.dayHoverBackground
: "transparent",
transition
)
)
foreground.set(
withTiming(
active ? colors.day : hovered ? colors.dayHoverText : colors.muted,
transition
)
)
}, [
active,
hovered,
colors.active,
colors.day,
colors.muted,
colors.dayHoverBackground,
colors.dayHoverText,
])
useEffect(() => {
scale.set(
withSpring(active ? 1.2 : 1, {
stiffness: 550,
damping: 30,
})
)
}, [active])
const motion = useAnimatedStyle(() => ({
transform: [{ scale: scale.get() }],
backgroundColor: background.get(),
borderRadius: 19,
}))
const textColor = useAnimatedStyle(() => ({ color: foreground.get() }))
return (
<Animated.View style={motion}>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Day ${day}`}
accessibilityState={{ selected: active }}
onPress={choose}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
style={{
width: 37,
height: 37,
borderRadius: 19,
alignItems: "center",
justifyContent: "center",
}}
>
<Animated.Text
style={[
{
fontFamily,
fontSize: 16,
fontWeight: "700",
},
textColor,
]}
>
{String(day).padStart(2, "0")}
</Animated.Text>
</Pressable>
</Animated.View>
)
}
export function JournalNavigation({
entries,
initialIndex = 0,
onEntryChange,
theme = "system",
width = 360,
height = 340,
radius = 32,
fontFamily = Platform.OS === "web" ? "Arial" : undefined,
colors: overrides,
style,
testID,
}: JournalNavigationProps) {
const [index, setIndex] = useState(
Math.max(0, Math.min(initialIndex, entries.length - 1))
),
[direction, setDirection] = useState(0),
[outgoing, setOutgoing] = useState<JournalEntry[]>([]),
[glyphs, setGlyphs] = useState<Record<string, number>>({})
const scheme = useColorScheme(),
dark = theme === "dark" || (theme === "system" && scheme === "dark"),
colors = {
surface: dark ? "#171717" : "#f3efe9",
border: dark ? "#262626" : "#e5e4de80",
rail: dark ? "#262626" : "#fefefe",
active: dark ? "#404040" : "#f0ece6",
day: dark ? "#fff" : "#1c1c1e",
muted: dark ? "#525252" : "#b0afb8",
dayHoverBackground: dark ? "transparent" : "#f0ece6",
dayHoverText: dark ? "#a3a3a3" : "#b0afb8",
text: dark ? "#e5e5e5" : "#292422",
date: dark ? "#a3a3a3" : "#918d87",
...overrides,
}
const current = entries[index],
currentRef = useRef(index),
timers = useRef(
new Map<JournalEntry["id"], ReturnType<typeof setTimeout>>()
),
y = useSharedValue(-index * 45),
origin = useSharedValue(0),
entryIndex = useSharedValue(index)
useEffect(() => {
if (current) onEntryChange?.(current)
entryIndex.set(index)
y.set(withSpring(-index * 45, { stiffness: 260, damping: 32, mass: 0.6 }))
}, [index, entries])
useEffect(
() => () => {
timers.current.forEach(clearTimeout)
timers.current.clear()
cancelAnimation(y)
},
[]
)
function select(next: number) {
next = Math.max(0, Math.min(entries.length - 1, next))
const old = currentRef.current
if (next === old) return
setDirection(next > old ? 1 : -1)
currentRef.current = next
const previous = entries[old]
for (const id of [previous.id, entries[next].id]) {
const timer = timers.current.get(id)
if (timer !== undefined) clearTimeout(timer)
timers.current.delete(id)
}
setOutgoing((rows) => [
...rows.filter(
(row) => row.id !== previous.id && row.id !== entries[next].id
),
previous,
])
setIndex(next)
timers.current.set(
previous.id,
setTimeout(() => {
timers.current.delete(previous.id)
setOutgoing((rows) => rows.filter((row) => row.id !== previous.id))
}, 1500)
)
}
const pan = Gesture.Pan()
.activeOffsetY([-3, 3])
.onStart(() => {
cancelAnimation(y)
origin.set(y.get())
})
.onUpdate((e) => {
const next = origin.get() + e.translationY,
minimum = -(entries.length - 1) * 45
y.set(
next > 0
? next * 0.35
: next < minimum
? minimum + (next - minimum) * 0.35
: next
)
})
.onEnd((e, success) => {
if (!success) {
y.set(
withSpring(-entryIndex.get() * 45, {
stiffness: 260,
damping: 32,
mass: 0.6,
})
)
return
}
let move = Math.floor((Math.abs(e.translationY) + 15) / 45)
if (Math.abs(e.velocityY) > 200 && move === 0) move = 1
const next = Math.max(
0,
Math.min(
entries.length - 1,
entryIndex.get() + (e.translationY < 0 ? 1 : -1) * move
)
)
y.set(withSpring(-next * 45, { stiffness: 260, damping: 32, mass: 0.6 }))
scheduleOnRN(select, next)
})
const rail = useAnimatedStyle(() => ({
transform: [{ translateY: y.get() }],
}))
const contentWidth = width - 96
function letters(entry: JournalEntry) {
const paragraphs =
typeof entry.content === "string"
? [entry.content]
: Array.isArray(entry.content) &&
entry.content.every((p) => typeof p === "string")
? (entry.content as string[])
: null
if (!paragraphs) return null
let global = 0,
line = 0,
paragraphY = 0
const result: { char: string; x: number; y: number; index: number }[] = []
for (const paragraph of paragraphs) {
let x = 0
for (const segment of paragraph.split(/(\s+)/)) {
const length = Array.from(segment).reduce(
(sum, c) => sum + (glyphs[c] ?? 9),
0
)
if (x + length > contentWidth && x > 0 && !/^\s+$/.test(segment)) {
line++
x = 0
}
for (const char of Array.from(segment)) {
if (!/\s/.test(char))
result.push({
char,
x,
y: 21 + paragraphY + line * 29.25,
index: global,
})
x += glyphs[char] ?? 9
global++
}
}
paragraphY += (line + 1) * 29.25 + 16
line = 0
}
return result
}
const chars = Array.from(
new Set(
entries.flatMap((e) =>
typeof e.content === "string"
? Array.from(e.content)
: Array.isArray(e.content)
? Array.from(
e.content.filter((p) => typeof p === "string").join("")
)
: []
)
)
)
if (!current) return null
return (
<View
testID={testID}
style={[
{
alignSelf: "center",
width,
height,
borderRadius: radius,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
overflow: "hidden",
flexDirection: "row",
boxShadow: "0px 1px 2px rgba(0,0,0,.05)",
},
style,
]}
>
<GestureDetector gesture={pan}>
<View
style={{
width: 54,
margin: 4,
borderRadius: 999,
borderWidth: 1,
borderColor: dark ? "#404040" : colors.border,
backgroundColor: colors.rail,
overflow: "hidden",
}}
>
<Animated.View
style={[
{
position: "absolute",
top: (height - 10) / 2 - 18.5,
left: 0,
right: 0,
alignItems: "center",
gap: 8,
},
rail,
]}
>
{entries.map((entry, i) => (
<Day
key={entry.id}
day={entry.day}
active={i === index}
choose={() => select(i)}
colors={colors}
fontFamily={fontFamily}
/>
))}
</Animated.View>
<BlurView
experimentalBlurMethod="dimezisBlurView"
pointerEvents="none"
intensity={2}
tint={dark ? "dark" : "light"}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 80,
}}
/>
<RailFade color={colors.rail} />
<BlurView
experimentalBlurMethod="dimezisBlurView"
pointerEvents="none"
intensity={2}
tint={dark ? "dark" : "light"}
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 80,
}}
/>
<RailFade color={colors.rail} bottom />
</View>
</GestureDetector>
<View style={{ flex: 1, padding: 16 }}>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 24,
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
overflow: "hidden",
}}
>
<View style={{ marginRight: 4 }}>
<DatePart
text={current.month}
direction={direction}
fontFamily={fontFamily}
color={colors.date}
/>
</View>
{String(current.day)
.split("")
.map((digit, i) => (
<DatePart
key={i}
text={digit}
direction={direction}
fontFamily={fontFamily}
color={colors.date}
/>
))}
</View>
<View style={{ flexDirection: "row", gap: 8 }}>
{[-1, 1].map((delta) => (
<Pressable
key={delta}
accessibilityRole="button"
accessibilityLabel={delta < 0 ? "Previous entry" : "Next entry"}
disabled={
delta < 0 ? index === 0 : index === entries.length - 1
}
onPress={() => select(index + delta)}
hitSlop={6}
style={{
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.rail,
alignItems: "center",
justifyContent: "center",
opacity:
dark &&
(delta < 0 ? index === 0 : index === entries.length - 1)
? 0.2
: 1,
}}
>
<Svg width={20} height={20} viewBox="0 0 24 24">
<Path
d={delta < 0 ? "m15 18-6-6 6-6" : "m9 18 6-6-6-6"}
fill="none"
stroke={dark ? "#737373" : "#b8b8b5"}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
</Pressable>
))}
</View>
</View>
<View style={{ flex: 1, overflow: "hidden" }}>
{[...outgoing, current].map((entry, i) => {
const runs = letters(entry)
return runs ? (
<Svg
key={entry.id}
width={contentWidth}
height={height - 88}
style={{ position: "absolute", top: 0, left: 0 }}
>
{runs.map((run) => (
<Letter
key={run.index}
{...run}
exit={entry !== current}
direction={direction}
color={colors.text}
fontFamily={fontFamily}
/>
))}
</Svg>
) : (
<View
key={entry.id}
style={{
position: "absolute",
top: 0,
left: 0,
width: contentWidth,
}}
>
{entry.content}
</View>
)
})}
</View>
</View>
<View pointerEvents="none" style={{ position: "absolute", opacity: 0 }}>
{chars.map((char) => (
<Text
key={char}
onLayout={(e) => {
const width = e.nativeEvent.layout.width
setGlyphs((old) =>
old[char] === width ? old : { ...old, [char]: width }
)
}}
style={{
alignSelf: "flex-start",
fontFamily,
fontSize: 18,
fontWeight: "700",
letterSpacing: -0.45,
}}
>
{char === " " ? "\u00a0" : char}
</Text>
))}
</View>
</View>
)
}
export default JournalNavigation
function RailFade({
color,
bottom = false,
}: {
color: string
bottom?: boolean
}) {
const id = useId().replace(/\W/g, "")
return (
<Svg
pointerEvents="none"
width="100%"
height={80}
style={{ position: "absolute", left: 0, [bottom ? "bottom" : "top"]: 0 }}
>
<Defs>
<SvgGradient id={id} x1="0%" x2="0%" y1="0%" y2="100%">
<Stop offset="0" stopColor={color} stopOpacity={bottom ? 0 : 1} />
<Stop offset="0.5" stopColor={color} stopOpacity={0.8} />
<Stop offset="1" stopColor={color} stopOpacity={bottom ? 1 : 0} />
</SvgGradient>
</Defs>
<Rect width="100%" height={80} fill={`url(#${id})`} />
</Svg>
)
}
Give the gestures and motion a run on a real Android phone.