Skip to content
pixelfor Expo
Ctrl K

Interaction

Journal Navigation

An expressive interaction rebuilt for touch

Try it on your phone

Journal Navigation

Scan to open Journal Navigation 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.

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.

Back to Watermelon UI