Feedback
Notification Bell
New notifications give the bell and its clapper a physical swing
Expo code
Original web component
Rare UIOur Expo component
React Native WebComponent 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.