Skip to content
pixelfor Expo
Ctrl K

Interaction

Event Reminders

An expressive interaction rebuilt for touch

Try it on your phone

Event Reminders

Scan to open Event Reminders 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.

event-reminders.in-progress.tsx

import React, { useCallback, useEffect, useId, useRef, useState } from "react"
import {
  Platform,
  Pressable,
  Text,
  TextInput,
  View,
  useColorScheme,
  useWindowDimensions,
  type StyleProp,
  type ViewStyle,
  type PressableProps,
} from "react-native"
import Animated, {
  defineAnimation,
  type AnimationObject,
  cubicBezier,
  Easing,
  LinearTransition,
  useAnimatedProps,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, {
  Defs,
  Filter,
  FeGaussianBlur,
  G,
  Path,
  Text as SvgText,
} from "react-native-svg"
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
}
export type ReminderType = "Notification" | "Email"
export type TimeUnit = "minutes" | "hours" | "days"
export interface Reminder {
  id: string
  type: ReminderType
  value: number
  unit: TimeUnit
}
export interface EventRemindersProps {
  title?: string
  date?: string
  initialReminders?: Reminder[]
  onUpdate?: (reminders: Reminder[]) => void
  onDateChange?: (date: string) => void
  width?: number
  radius?: number
  fontFamily?: string
  compact?: boolean
  theme?: "light" | "dark" | "system"
  colors?: Partial<{
    surface: string
    border: string
    text: string
    muted: string
    secondary: string
    soft: string
    softText: string
    softHoverText: string
    addHover: string
    editSurface: string
    editBorder: string
    editHover: string
    removeBorder: string
    removeHover: string
    removeHoverText: string
    inputText: string
    inputBorder: string
  }>
  style?: StyleProp<ViewStyle>
  testID?: string
}
type Palette = Required<NonNullable<EventRemindersProps["colors"]>>
function critical(p: number) {
  "worklet"
  const t = 9.233413476 * p
  return p >= 1 ? 1 : 1 - Math.exp(-t) * (1 + t)
}
function rowSpring(p: number) {
  "worklet"
  const omega = Math.log(0.8 / 0.6 / 0.001) / (0.8 * 0.5),
    t = p * 0.5
  return p >= 1
    ? 1
    : 1 -
        Math.exp(-omega * 0.8 * t) *
          (Math.cos(omega * 0.6 * t) + (0.8 / 0.6) * Math.sin(omega * 0.6 * t))
}
const LAYOUT = LinearTransition.duration(500).easing(rowSpring)
const FADE = { duration: 300, easing: Easing.bezier(0.25, 0.1, 0.35, 1) }
const COLOR = { duration: 150, easing: Easing.bezier(0.4, 0, 0.2, 1) }
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
const AnimatedSvg = Animated.createAnimatedComponent(Svg)
function HoverButton({
  children,
  style,
  color,
  hoverColor = color,
  hoverBackground,
  ...props
}: Omit<PressableProps, "children" | "style"> & {
  children: React.ReactNode | ((foreground: string) => React.ReactNode)
  style?: StyleProp<ViewStyle>
  color?: string
  hoverColor?: string
  hoverBackground?: string
}) {
  const [hovered, setHovered] = useState(false)
  return (
    <AnimatedPressable
      {...props}
      onHoverIn={() => setHovered(true)}
      onHoverOut={() => setHovered(false)}
      style={[
        style,
        hovered && hoverBackground ? { backgroundColor: hoverBackground } : {},
        {
          transitionProperty: ["backgroundColor", "borderColor"],
          transitionDuration: 150,
          transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
        },
      ]}
    >
      {typeof children === "function"
        ? children(hovered ? hoverColor! : color!)
        : children}
    </AnimatedPressable>
  )
}
const enterRow = () => {
  "worklet"
  return {
    initialValues: { opacity: 0, transform: [{ translateY: 10 }] },
    animations: {
      opacity: withTiming(1, { duration: 500, easing: rowSpring }),
      transform: [
        { translateY: withTiming(0, { duration: 500, easing: rowSpring }) },
      ],
    },
  }
}
const exitRow = () => {
  "worklet"
  return {
    initialValues: { opacity: 1, transform: [{ translateY: 0 }] },
    animations: {
      opacity: withTiming(0, { duration: 500, easing: rowSpring }),
      transform: [
        { translateY: withTiming(-10, { duration: 500, easing: rowSpring }) },
      ],
    },
  }
}
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)
export function EventReminders({
  title = "Design review",
  date: initialDate = "Friday, October 2 · 10:00 AM",
  initialReminders = [],
  onUpdate,
  onDateChange,
  width = 400,
  radius = 32,
  fontFamily = Platform.OS === "web" ? "Arial" : undefined,
  compact,
  theme = "system",
  colors: overrides,
  style,
  testID,
}: EventRemindersProps) {
  const [reminders, setReminders] = useState(initialReminders),
    [date, setDate] = useState(initialDate)
  const [editing, setEditing] = useState(false),
    [shownEditing, setShownEditing] = useState(false)
  const nextId = useRef(0),
    prefix = useId()
  const window = useWindowDimensions(),
    scheme = useColorScheme(),
    small = compact ?? window.width < 640
  const dark = theme === "dark" || (theme === "system" && scheme === "dark")
  const colors = {
    surface: dark ? "#171717" : "#fff",
    border: dark ? "#262626" : "#e5e5e5",
    text: dark ? "#fff" : "#171717",
    muted: dark ? "#a3a3a3" : "#525252",
    secondary: dark ? "#e5e5e5" : "#262626",
    soft: dark ? "#262626" : "#f5f5f5",
    softText: dark ? "#a3a3a3" : "#737373",
    softHoverText: dark ? "#fff" : "#171717",
    addHover: dark ? "#404040" : "#e5e5e5",
    editSurface: dark ? "#262626" : "#fff",
    editBorder: dark ? "#404040" : "#e5e5e5",
    editHover: dark ? "#404040" : "#f5f5f5",
    removeBorder: dark ? "#404040" : "#e5e5e5",
    removeHover: dark ? "#262626" : "#f5f5f5",
    removeHoverText: "#ef4444",
    inputText: dark ? "#d4d4d4" : "#525252",
    inputBorder: dark ? "#404040" : "#d4d4d4",
    ...overrides,
  }
  const dateOpacity = useSharedValue(0)
  const swapDate = useCallback((next: boolean) => setShownEditing(next), [])
  useEffect(() => {
    if (editing === shownEditing) {
      dateOpacity.set(withTiming(1, FADE))
      return
    }
    dateOpacity.set(
      withTiming(0, FADE, (finished) => {
        if (finished) scheduleOnRN(swapDate, editing)
      })
    )
  }, [editing, shownEditing])
  const dateMotion = useAnimatedStyle(() => ({ opacity: dateOpacity.get() }))
  function change(next: Reminder[]) {
    setReminders(next)
    onUpdate?.(next)
  }
  function update(id: string, patch: Partial<Reminder>) {
    change(
      reminders.map((item) => (item.id === id ? { ...item, ...patch } : item))
    )
  }
  function add() {
    change([
      ...reminders,
      {
        id: `${prefix}-${nextId.current++}`,
        type: "Notification",
        value: 5,
        unit: "minutes",
      },
    ])
  }
  return (
    <Animated.View
      testID={testID}
      layout={LAYOUT}
      collapsable={false}
      style={[
        {
          alignSelf: "center",
          width: Math.min(width, window.width - 32),
          borderRadius: radius,
          borderWidth: 2,
          borderColor: colors.border,
          backgroundColor: colors.surface,
          transitionProperty: ["backgroundColor", "borderColor"],
          transitionDuration: 150,
          transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
          padding: 24,
          boxShadow:
            "0px 10px 15px -3px rgba(0,0,0,.1), 0px 4px 6px -4px rgba(0,0,0,.1)",
        },
        style,
      ]}
    >
      <View
        style={{
          flexDirection: "row",
          gap: 12,
          alignItems: "flex-start",
          marginBottom: 24,
        }}
      >
        <View style={{ flex: 1 }}>
          <Text
            style={{
              fontFamily,
              fontSize: small ? 18 : 20,
              lineHeight: (small ? 18 : 20) * 1.25,
              fontWeight: "700",
              color: colors.text,
            }}
          >
            {title}
          </Text>
          <Animated.View style={[{ marginTop: 8 }, dateMotion]}>
            {shownEditing ? (
              <TextInput
                autoFocus
                value={date}
                onChangeText={(value) => {
                  setDate(value)
                  onDateChange?.(value)
                }}
                onSubmitEditing={() => setEditing(false)}
                returnKeyType="done"
                accessibilityLabel="Event date"
                style={{
                  fontFamily,
                  fontSize: 16,
                  lineHeight: 24,
                  fontWeight: "600",
                  color: colors.inputText,
                  backgroundColor: colors.soft,
                  borderRadius: 4,
                  borderBottomWidth: 2,
                  borderBottomColor: colors.inputBorder,
                  paddingHorizontal: 8,
                  paddingVertical: 4,
                }}
              />
            ) : (
              <Text
                style={{
                  fontFamily,
                  fontSize: 16,
                  lineHeight: 24,
                  fontWeight: "600",
                  color: colors.muted,
                }}
              >
                {date}
              </Text>
            )}
          </Animated.View>
        </View>
        <HoverButton
          accessibilityRole="button"
          accessibilityLabel={editing ? "Save date" : "Edit date"}
          hoverBackground={colors.editHover}
          onPress={() => setEditing(!editing)}
          style={{
            padding: 8,
            borderWidth: 2,
            borderColor: colors.editBorder,
            borderRadius: 8,
            backgroundColor: colors.editSurface,
          }}
        >
          <Svg
            width={20}
            height={20}
            viewBox={editing ? "0 0 448 512" : "0 0 24 24"}
            fill={colors.muted}
          >
            <Path
              d={
                editing
                  ? "M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
                  : "M8.707 19.707 18 10.414 13.586 6l-9.293 9.293a1.003 1.003 0 0 0-.263.464L3 21l5.242-1.03c.176-.044.337-.135.465-.263zM21 7.414a2 2 0 0 0 0-2.828L19.414 3a2 2 0 0 0-2.828 0L15 4.586 19.414 9 21 7.414z"
              }
            />
          </Svg>
        </HoverButton>
      </View>
      <Animated.View
        layout={LAYOUT}
        collapsable={false}
        style={{
          borderTopWidth: 1,
          borderStyle: "dashed",
          borderColor: colors.border,
          paddingTop: 8,
        }}
      >
        {reminders.map((reminder) => (
          <ReminderRow
            key={reminder.id}
            reminder={reminder}
            update={(patch) => update(reminder.id, patch)}
            remove={() =>
              change(reminders.filter((item) => item.id !== reminder.id))
            }
            colors={colors}
            fontFamily={fontFamily}
            small={small}
          />
        ))}
      </Animated.View>
      <Animated.View layout={LAYOUT} style={{ marginTop: 24 }}>
        <HoverButton
          accessibilityRole="button"
          onPress={add}
          hoverBackground={colors.addHover}
          style={{
            flexDirection: "row",
            alignItems: "center",
            justifyContent: "center",
            gap: 8,
            backgroundColor: colors.soft,
            paddingVertical: 12,
            borderRadius: 16,
          }}
        >
          <LineIcon kind="plus" color={colors.secondary} size={18} />
          <Text
            style={{
              fontFamily,
              fontSize: 16,
              lineHeight: 24,
              fontWeight: "600",
              color: colors.secondary,
            }}
          >
            Add Reminder
          </Text>
        </HoverButton>
      </Animated.View>
    </Animated.View>
  )
}
function LineIcon({
  kind,
  size = 16,
  color,
}: {
  kind: "plus" | "minus" | "x"
  size?: number
  color: string
}) {
  const stroke = useSharedValue(color)
  useEffect(() => {
    stroke.set(withTiming(color, COLOR))
  }, [color])
  const colorProps = useAnimatedProps(() => ({ stroke: stroke.get() }))
  return (
    <AnimatedSvg
      animatedProps={colorProps}
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      strokeWidth={kind === "minus" ? 2 : 2.5}
      strokeLinecap="round"
    >
      <Path
        d={
          kind === "plus"
            ? "M12 5v14M5 12h14"
            : kind === "minus"
              ? "M5 12h14"
              : "m18 6-12 12M6 6l12 12"
        }
      />
    </AnimatedSvg>
  )
}
function Chevrons() {
  return (
    <View style={{ height: 24 }}>
      {[true, false].map((up) => (
        <Svg
          key={String(up)}
          width={14}
          height={14}
          viewBox="0 0 24 24"
          fill="none"
          stroke="#a3a3a3"
          strokeWidth={3}
          strokeLinecap="round"
          strokeLinejoin="round"
          style={up ? undefined : { marginTop: -4 }}
        >
          <Path d={up ? "m18 15-6-6-6 6" : "m6 9 6 6 6-6"} />
        </Svg>
      ))}
    </View>
  )
}
function ReminderRow({
  reminder,
  update,
  remove,
  colors,
  fontFamily,
  small,
}: {
  reminder: Reminder
  update: (patch: Partial<Reminder>) => void
  remove: () => void
  colors: Palette
  fontFamily?: string
  small: boolean
}) {
  const [numberMotion, setNumberMotion] = useState({
    value: reminder.value,
    direction: 1,
  })
  if (numberMotion.value !== reminder.value)
    setNumberMotion({
      value: reminder.value,
      direction: reminder.value >= numberMotion.value ? 1 : -1,
    })
  const direction = numberMotion.direction
  const pill = {
    borderWidth: 1.6,
    borderColor: colors.border,
    borderRadius: 999,
    backgroundColor: colors.surface,
  }
  return (
    <Animated.View
      entering={enterRow}
      exiting={exitRow}
      layout={LAYOUT}
      collapsable={false}
      style={{
        paddingVertical: 16,
        borderBottomWidth: 1,
        borderColor: colors.border,
        borderStyle: "dashed",
        gap: 12,
      }}
    >
      <AnimatedPressable
        accessibilityRole="button"
        accessibilityLabel={`Reminder type: ${reminder.type}`}
        onPress={() =>
          update({
            type: reminder.type === "Notification" ? "Email" : "Notification",
          })
        }
        style={[
          pill,
          {
            paddingHorizontal: 16,
            paddingVertical: 8,
            flexDirection: "row",
            justifyContent: "space-between",
            alignItems: "center",
            transitionProperty: ["backgroundColor", "borderColor"],
            transitionDuration: 150,
            transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
          },
        ]}
      >
        <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
          <Swap
            value={reminder.type}
            width={18}
            height={24}
            blur={4}
            duration={500}
            render={(value) => (
              <Svg
                width={18}
                height={18}
                viewBox={value === "Notification" ? "0 0 448 512" : "0 0 24 24"}
                fill="#a3a3a3"
              >
                <Path
                  d={
                    value === "Notification"
                      ? "M224 0c-17.7 0-32 14.3-32 32l0 19.2C119 66 64 130.6 64 208l0 18.8c0 47-17.3 92.4-48.5 127.6l-7.4 8.3c-8.4 9.4-10.4 22.9-5.3 34.4S19.4 416 32 416l384 0c12.6 0 24-7.4 29.2-18.9s3.1-25-5.3-34.4l-7.4-8.3C401.3 319.2 384 273.9 384 226.8l0-18.8c0-77.4-55-142-128-156.8L256 32c0-17.7-14.3-32-32-32zm45.3 493.3c12-12 18.7-28.3 18.7-45.3l-64 0-64 0c0 17 6.7 33.3 18.7 45.3s28.3 18.7 45.3 18.7s33.3-6.7 45.3-18.7z"
                      : "M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m0 4-8 5-8-5V6l8 5 8-5z"
                  }
                />
              </Svg>
            )}
          />
          <Word
            value={reminder.type}
            color={colors.secondary}
            fontFamily={fontFamily}
          />
        </View>
        <Chevrons />
      </AnimatedPressable>
      <View
        style={{
          flexDirection: "row",
          flexWrap: small ? "wrap" : "nowrap",
          gap: 8,
          alignItems: "center",
        }}
      >
        <Animated.View
          style={[
            pill,
            {
              flex: 1,
              minWidth: 120,
              paddingHorizontal: 8,
              paddingVertical: 4,
              flexDirection: "row",
              alignItems: "center",
              justifyContent: "space-between",
              transitionProperty: ["backgroundColor", "borderColor"],
              transitionDuration: 150,
              transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
            },
          ]}
        >
          <HoverButton
            accessibilityRole="button"
            accessibilityLabel="Decrease reminder time"
            color={colors.softText}
            hoverColor={colors.softHoverText}
            hitSlop={4}
            onPress={() => update({ value: Math.max(1, reminder.value - 1) })}
            style={{
              padding: 8,
              borderRadius: 999,
              backgroundColor: colors.soft,
            }}
          >
            {(color) => <LineIcon kind="minus" color={color} />}
          </HoverButton>
          <View style={{ flexDirection: "row", overflow: "hidden" }}>
            {String(reminder.value)
              .padStart(2, "0")
              .split("")
              .map((char, i) => (
                <Swap
                  key={i}
                  value={char}
                  width={11}
                  height={28}
                  blur={2}
                  scaleFrom={0}
                  direction={direction}
                  render={(value) => (
                    <SvgText
                      x={5.5}
                      y={20}
                      textAnchor="middle"
                      fontFamily={fontFamily}
                      fontSize={18}
                      fontWeight="700"
                      fill={colors.text}
                    >
                      {value}
                    </SvgText>
                  )}
                />
              ))}
          </View>
          <HoverButton
            accessibilityRole="button"
            accessibilityLabel="Increase reminder time"
            color={colors.softText}
            hoverColor={colors.softHoverText}
            hitSlop={4}
            onPress={() => update({ value: reminder.value + 1 })}
            style={{
              padding: 8,
              borderRadius: 999,
              backgroundColor: colors.soft,
            }}
          >
            {(color) => <LineIcon kind="plus" color={color} />}
          </HoverButton>
        </Animated.View>
        <AnimatedPressable
          layout={LAYOUT}
          accessibilityRole="button"
          accessibilityLabel={`Time unit: ${reminder.unit}`}
          onPress={() => {
            const units: TimeUnit[] = ["minutes", "hours", "days"]
            update({
              unit: units[(units.indexOf(reminder.unit) + 1) % units.length],
            })
          }}
          style={[
            pill,
            {
              flex: 1.4,
              minWidth: 120,
              paddingHorizontal: 16,
              paddingVertical: 8,
              flexDirection: "row",
              alignItems: "center",
              justifyContent: "space-between",
              transitionProperty: ["backgroundColor", "borderColor"],
              transitionDuration: 150,
              transitionTimingFunction: cubicBezier(0.4, 0, 0.2, 1),
            },
          ]}
        >
          <Word
            value={reminder.unit}
            color={colors.secondary}
            fontFamily={fontFamily}
          />
          <Chevrons />
        </AnimatedPressable>
        <HoverButton
          accessibilityRole="button"
          accessibilityLabel="Remove reminder"
          color={colors.softText}
          hoverColor={colors.removeHoverText}
          hoverBackground={colors.removeHover}
          hitSlop={4}
          onPress={remove}
          style={{
            padding: small ? 10 : 12,
            borderWidth: 1,
            borderColor: colors.removeBorder,
            borderRadius: 999,
          }}
        >
          {(color) => <LineIcon kind="x" size={18} color={color} />}
        </HoverButton>
      </View>
    </Animated.View>
  )
}
type WordGlyph = {
  id: number
  key: string
  char: string
  index: number
  x: number
  width: number
  exit: boolean
  initial: boolean
}
function Word({
  value,
  color,
  fontFamily,
}: {
  value: string
  color: string
  fontFamily?: string
}) {
  const chars = value
    .split("")
    .map((char, i) => (i === 0 ? char.toUpperCase() : char))
  const [widths, setWidths] = useState<Record<string, number>>({})
  const serial = useRef(0),
    mounted = useRef(false)
  const [layers, setLayers] = useState<WordGlyph[]>([])
  let total = 0
  const target = chars.map((char, index) => {
    const width = widths[char] ?? 9,
      glyph = { key: char + ":" + index, char, index, x: total, width }
    total += width
    return glyph
  })
  useEffect(() => {
    const initial = !mounted.current
    mounted.current = true
    setLayers((previous) => {
      const retained = previous
        .filter((layer) => !target.some((glyph) => glyph.key === layer.key))
        .map((layer) => ({ ...layer, exit: true }))
      return [
        ...retained,
        ...target.map((glyph) => {
          const old = previous.find((layer) => layer.key === glyph.key)
          return {
            ...glyph,
            id: old?.id ?? serial.current++,
            initial: old?.initial ?? initial,
            exit: false,
          }
        }),
      ]
    })
  }, [value, widths])
  const remove = useCallback(
    (id: number) =>
      setLayers((previous) =>
        previous.filter((layer) => layer.id !== id || !layer.exit)
      ),
    []
  )
  return (
    <View style={{ width: total, height: 24, overflow: "hidden" }}>
      <View
        pointerEvents="none"
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        style={{ position: "absolute", opacity: 0, flexDirection: "row" }}
      >
        {Array.from(new Set(chars)).map((char) => (
          <Text
            key={char}
            onLayout={(event) => {
              const width = event.nativeEvent.layout.width
              setWidths((previous) =>
                previous[char] === width
                  ? previous
                  : { ...previous, [char]: width }
              )
            }}
            style={{ fontFamily, fontSize: 16, fontWeight: "600" }}
          >
            {char}
          </Text>
        ))}
      </View>
      {layers.map((layer) => (
        <View
          key={layer.id}
          style={{
            position: "absolute",
            left: layer.x,
            top: 0,
            width: layer.width,
            height: 24,
          }}
        >
          <SwapLayer
            id={layer.id}
            exit={layer.exit}
            initial={layer.initial}
            width={layer.width}
            height={24}
            blur={0}
            scaleFrom={1}
            direction={1}
            delay={layer.index * 17.5}
            duration={300}
            remove={remove}
          >
            <SvgText
              x={0}
              y={18}
              fontFamily={fontFamily}
              fontSize={16}
              fontWeight="600"
              fill={color}
            >
              {layer.char}
            </SvgText>
          </SwapLayer>
        </View>
      ))}
    </View>
  )
}
type SwapProps = {
  value: string
  width: number
  height: number
  render: (value: string) => React.ReactNode
  duration?: number
  blur?: number
  scaleFrom?: number
  direction?: number
  delay?: number
}
function Swap({
  value,
  width,
  height,
  render,
  duration = 300,
  blur = 0,
  scaleFrom = 1,
  direction = 1,
  delay = 0,
}: SwapProps) {
  const [layers, setLayers] = useState([
      { id: 0, value, exit: false, initial: true },
    ]),
    next = useRef(1),
    last = useRef(value)
  useEffect(() => {
    if (last.current === value) return
    last.current = value
    setLayers((current) => {
      const returning = current.some((layer) => layer.value === value)
      const layers = current.map((layer) => ({
        ...layer,
        exit: layer.value !== value,
      }))
      return returning
        ? layers
        : [
            ...layers,
            { id: next.current++, value, exit: false, initial: false },
          ]
    })
  }, [value])
  const remove = useCallback(
    (id: number) =>
      setLayers((current) =>
        current.filter((layer) => layer.id !== id || !layer.exit)
      ),
    []
  )
  return (
    <View style={{ width, height }}>
      {layers.map((layer) => (
        <SwapLayer
          key={layer.id}
          {...layer}
          width={width}
          height={height}
          blur={blur}
          scaleFrom={scaleFrom}
          direction={direction}
          delay={delay}
          duration={duration}
          remove={remove}
        >
          {render(layer.value)}
        </SwapLayer>
      ))}
    </View>
  )
}
function SwapLayer({
  id: key,
  exit,
  initial,
  width,
  height,
  blur,
  scaleFrom,
  direction,
  delay,
  duration,
  remove,
  children,
}: {
  id: number
  exit: boolean
  initial: boolean
  width: number
  height: number
  blur: number
  scaleFrom: number
  direction: number
  delay: number
  duration: number
  remove: (id: number) => void
  children: React.ReactNode
}) {
  const id = useId().replace(/\W/g, ""),
    p = useSharedValue(initial ? 1 : 0),
    y = useSharedValue(initial ? 0 : direction * 5)
  useEffect(() => {
    p.set(
      motionDelay(
        delay,
        withTiming(exit ? 0 : 1, { duration, easing: critical }, (finished) => {
          if (finished && exit) scheduleOnRN(remove, key)
        })
      )
    )
    y.set(
      motionDelay(
        delay,
        withTiming(exit ? -direction * 5 : 0, { duration, easing: critical })
      )
    )
  }, [exit, direction, delay, duration])
  const motion = useAnimatedProps(() => ({
    opacity: p.get(),
    transform: `translate(${width / 2} ${height / 2 + y.get()}) scale(${scaleFrom + (1 - scaleFrom) * p.get()}) translate(${-width / 2} ${-height / 2})`,
  }))
  const filter = useAnimatedProps(() => ({
    stdDeviation: blur * (1 - p.get()),
  }))
  return (
    <Svg
      width={width}
      height={height}
      style={{ position: "absolute", overflow: "visible" }}
    >
      <Defs>
        <Filter id={id} x="-100%" y="-50%" width="300%" height="200%">
          <AB animatedProps={filter} />
        </Filter>
      </Defs>
      <AG animatedProps={motion}>
        <G filter={blur ? `url(#${id})` : undefined}>{children}</G>
      </AG>
    </Svg>
  )
}
export default EventReminders

Give the gestures and motion a run on a real Android phone.

Back to Watermelon UI