Skip to content
pixelfor Expo
Ctrl K

Feedback

Notification Bell

New notifications give the bell and its clapper a physical swing

Try it on your phone

Notification Bell

Scan to open Notification Bell 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

Rare UI

Our Expo component

React Native Web

Component source

Make it yours

One file to copy into your app.

notification-bell.tsx

import React, { useEffect, useRef } from "react"
import { Pressable, Text, View } from "react-native"
import Animated, {
  FadeInDown,
  FadeOutUp,
  ReduceMotion,
  useAnimatedStyle,
  useFrameCallback,
  useReducedMotion,
  useSharedValue,
  withSpring,
} from "react-native-reanimated"
import Svg, { Path } from "react-native-svg"
export interface NotificationBellProps {
  count?: number
  defaultCount?: number
  max?: number
  variant?: "count" | "dot"
  size?: number
  color?: "red" | "orange" | "green" | "blue" | "violet"
  backgroundColor?: string
  iconColor?: string
  onPress?: () => void
  testID?: string
}
const colors = {
  red: "#FF453A",
  orange: "#FF9F0A",
  green: "#30D158",
  blue: "#0A84FF",
  violet: "#BF5AF2",
}
function springStep(position: number, velocity: number, target: number, stiffness: number, damping: number, dt: number) {
  "worklet"
  const decay = damping / 2
  const frequency = Math.sqrt(stiffness - decay * decay)
  const displacement = position - target
  const envelope = Math.exp(-decay * dt)
  const cosine = Math.cos(frequency * dt)
  const sine = Math.sin(frequency * dt)
  return [
    target + envelope * (displacement * cosine + (velocity + decay * displacement) / frequency * sine),
    envelope * (velocity * cosine - (decay * velocity + stiffness * displacement) / frequency * sine),
  ]
}
export function NotificationBell({
  count,
  defaultCount = 0,
  max = 99,
  variant = "count",
  size = 48,
  color = "red",
  backgroundColor = "#262626",
  iconColor = "#9b9aa7",
  onPress,
  testID,
}: NotificationBellProps) {
  const unread = Math.max(0, Math.floor(count ?? defaultCount)),
    previous = useRef(unread),
    reduced = useReducedMotion()
  const bell = useSharedValue(0),
    bellVelocity = useSharedValue(0),
    clapper = useSharedValue(0),
    clapperVelocity = useSharedValue(0),
    scale = useSharedValue(unread > 0 ? 1 : 0)
  useFrameCallback(({ timeSincePreviousFrame }) => {
    if (reduced || timeSincePreviousFrame == null) return
    if (Math.abs(bellVelocity.get()) + Math.abs(bell.get()) + Math.abs(clapperVelocity.get()) + Math.abs(clapper.get()) < 0.01) return
    const dt = Math.min(timeSincePreviousFrame, 64) / 1000
    const [angle, velocity] = springStep(bell.get(), bellVelocity.get(), 0, 220, 10, dt)
    bell.set(angle)
    bellVelocity.set(velocity)
    const target = Math.max(-13, Math.min(13, -velocity / 450 * 13))
    const [lag, lagVelocity] = springStep(clapper.get(), clapperVelocity.get(), target, 300, 14, dt)
    clapper.set(lag)
    clapperVelocity.set(lagVelocity)
  })
  useEffect(() => {
    const delta = unread - previous.current
    previous.current = unread
    if (delta > 0 && !reduced) {
      const impulse = 500 * (0.7 + (0.6 * Math.min(delta, 5)) / 5)
      const moving = bellVelocity.get()
      bellVelocity.set(Math.max(-900, Math.min(900, moving + (moving > 1 ? 1 : -1) * impulse)))
    }
    scale.value = reduced
      ? Number(unread > 0)
      : withSpring(Number(unread > 0), { damping: 20, stiffness: 600 })
  }, [unread, reduced, bellVelocity, scale])
  const swing = useAnimatedStyle(() => ({
    transformOrigin: "50% 12%",
    transform: [{ rotate: `${Math.max(-35, Math.min(35, bell.value))}deg` }],
  }))
  const inertial = useAnimatedStyle(() => ({
    transformOrigin: "50% 83.33%",
    transform: [{ rotate: `${Math.max(-13, Math.min(13, clapper.value))}deg` }],
  }))
  const badge = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }))
  const badgeSize = size * (variant === "dot" ? 0.22 : 0.38),
    label = unread > max ? `${max}+` : String(unread)
  return (
    <Pressable
      testID={testID}
      accessibilityRole="button"
      accessibilityLabel={`Notifications, ${unread} unread`}
      onPress={onPress}
      style={({ pressed }) => ({
        width: size,
        height: size,
        borderRadius: size / 2,
        backgroundColor,
        alignItems: "center",
        justifyContent: "center",
        transform: [{ scale: pressed && !reduced ? 0.9 : 1 }],
      })}
    >
      <Animated.View
        style={[{ width: size * 0.56, height: size * 0.56 }, swing]}
      >
        <Svg width="100%" height="100%" viewBox="0 0 18 18">
          <Path
            d="M3.5 6.5C3.5 3.46279 5.96279 1 9 1C12.0372 1 14.5 3.46279 14.5 6.5V10.75C14.5 11.4408 15.0592 12 15.75 12C16.1642 12 16.5 12.3358 16.5 12.75C16.5 13.1642 16.1642 13.5 15.75 13.5H2.25C1.83579 13.5 1.5 13.1642 1.5 12.75C1.5 12.3358 1.83579 12 2.25 12C2.94079 12 3.5 11.4408 3.5 10.75V6.5Z"
            fill={iconColor}
            fillOpacity={0.55}
          />
        </Svg>
        <Animated.View
          style={[
            { position: "absolute", width: "100%", height: "100%" },
            inertial,
          ]}
        >
          <Svg width="100%" height="100%" viewBox="0 0 18 18">
            <Path d="M10.2 15H7.80099C7.64999 15 7.50799 15.068 7.41299 15.185C7.31799 15.302 7.28099 15.456 7.31199 15.603C7.48499 16.425 8.17999 17 9.00099 17C9.82199 17 10.517 16.425 10.69 15.603C10.721 15.456 10.684 15.302 10.589 15.185C10.494 15.068 10.351 15 10.2 15Z" fill={iconColor} />
          </Svg>
        </Animated.View>
      </Animated.View>
      <Animated.View
        pointerEvents="none"
        style={[
          {
            position: "absolute",
            right: size / 2 - (0.9 * size * Math.SQRT1_2) / 2 - badgeSize / 2,
            top: size / 2 - (0.9 * size * Math.SQRT1_2) / 2 - badgeSize / 2,
            minWidth: badgeSize,
            height: badgeSize,
            paddingHorizontal: variant === "dot" ? 0 : size * 0.09,
            borderRadius: badgeSize / 2,
            backgroundColor: colors[color],
            alignItems: "center",
            justifyContent: "center",
            overflow: "hidden",
          },
          badge,
        ]}
      >
        {variant === "count" && (
          <Animated.Text
            key={label}
            entering={FadeInDown.duration(180).reduceMotion(
              ReduceMotion.System
            )}
            exiting={FadeOutUp.duration(180).reduceMotion(ReduceMotion.System)}
            style={{
              color: "#fff",
              fontSize: size * 0.21,
              fontWeight: "600",
              fontVariant: ["tabular-nums"],
            }}
          >
            {label}
          </Animated.Text>
        )}
      </Animated.View>
    </Pressable>
  )
}
export default NotificationBell

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

Back to Rare UI