Disclosure
Dynamic Island
A compact pill expands into measured call, music and timer views
Expo code
Original web component
Be UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
dynamic-island.tsx
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
import { Platform, View, type ViewStyle } from "react-native"
import Animated, { Easing, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring, withTiming } from "react-native-reanimated"
type Size = { width: number; height: number }
const COMPACT = "__compact"
const SHELL_SPRING = { duration: 800, dampingRatio: 0.8 }
const CONTENT_SPRING = { duration: 800, dampingRatio: 0.65 }
const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1)
const IslandContext = createContext<{
view: string | null
maxWidth: number
report: (id: string, size: Size) => void
} | null>(null)
export interface DynamicIslandProps {
view?: string | null
activeView?: string | null
compact?: React.ReactNode
children?: React.ReactNode
compactWidth?: number
compactHeight?: number
maxWidth?: number
backgroundColor?: string
radius?: number
style?: ViewStyle
testID?: string
}
export function DynamicIsland({
view,
activeView,
compact,
children,
compactWidth = 126,
compactHeight = 37,
maxWidth = 360,
backgroundColor = "#111111",
radius = 32,
style,
testID,
}: DynamicIslandProps) {
const selected = view === undefined ? activeView ?? null : view
const [sizes, setSizes] = useState<Record<string, Size>>({})
const width = useSharedValue(compactWidth)
const height = useSharedValue(compactHeight)
const reduced = useReducedMotion()
const report = useCallback((id: string, size: Size) => {
setSizes((previous) =>
previous[id]?.width === size.width && previous[id]?.height === size.height
? previous
: { ...previous, [id]: size }
)
}, [])
const context = useMemo(() => ({ view: selected, maxWidth, report }), [selected, maxWidth, report])
useEffect(() => {
const size = sizes[selected ?? COMPACT]
const nextWidth = Math.min(maxWidth, size?.width ?? compactWidth)
const nextHeight = size?.height ?? compactHeight
width.set(reduced ? nextWidth : withSpring(nextWidth, SHELL_SPRING))
height.set(reduced ? nextHeight : withSpring(nextHeight, SHELL_SPRING))
}, [selected, sizes, compactWidth, compactHeight, maxWidth, reduced, width, height])
const shell = useAnimatedStyle(() => ({ width: width.get(), height: height.get() }))
return (
<IslandContext.Provider value={context}>
<Animated.View
testID={testID}
accessibilityRole="summary"
accessibilityLiveRegion="polite"
style={[
{ backgroundColor, borderRadius: radius, overflow: "hidden", boxShadow: "0 25px 50px -12px rgba(0,0,0,0.25)" },
style,
shell,
]}
>
<View style={{ position: "absolute", top: 0, left: 0, width: maxWidth }}>
{compact ? (
<IslandSlot id={COMPACT} active={selected === null} style={{
minWidth: compactWidth, minHeight: compactHeight,
paddingHorizontal: 16, paddingVertical: 6, gap: 8,
}}>
{compact}
</IslandSlot>
) : null}
{children}
</View>
</Animated.View>
</IslandContext.Provider>
)
}
function IslandSlot({ id, active, children, style }: {
id: string
active: boolean
children: React.ReactNode
style?: ViewStyle
}) {
const context = useContext(IslandContext)!
const reduced = useReducedMotion()
const progress = useSharedValue(active ? 1 : 0)
useEffect(() => {
progress.set(active
? reduced ? withTiming(1, { duration: 150 }) : withSpring(1, CONTENT_SPRING)
: withTiming(0, { duration: reduced ? 100 : 80, easing: EASE_OUT }))
}, [active, reduced, progress])
const appearance = useAnimatedStyle(() => {
const value = progress.get()
const opacity = Math.max(0, Math.min(1, value))
return {
opacity,
transformOrigin: "top center",
transform: reduced ? [] : [
{ translateY: -(active ? 8 : 6) * (1 - value) },
{ scale: 0.9 + 0.1 * value },
],
...(Platform.OS === "web" ? { filter: `blur(${active && !reduced ? 5 * (1 - opacity) : 0}px)` } : {}),
}
})
return (
<Animated.View
pointerEvents={active ? "auto" : "none"}
aria-hidden={!active}
accessibilityElementsHidden={!active}
importantForAccessibility={active ? "auto" : "no-hide-descendants"}
onLayout={({ nativeEvent: { layout } }) => context.report(id, { width: layout.width, height: layout.height })}
style={[
{
position: "absolute", top: 0, left: 0, maxWidth: context.maxWidth,
flexDirection: "row", alignItems: "center", justifyContent: "center",
paddingHorizontal: 24, paddingVertical: 16,
},
style,
appearance,
]}
>
{children}
</Animated.View>
)
}
export function DynamicIslandView({ id, children, width, style }: {
id: string
children: React.ReactNode
width?: number
style?: ViewStyle
}) {
const context = useContext(IslandContext)
if (!context) throw new Error("DynamicIslandView must be used inside <DynamicIsland>")
return (
<IslandSlot id={id} active={context.view === id} style={{ ...(width === undefined ? {} : { width }), ...style }}>
{children}
</IslandSlot>
)
}
Give the gestures and motion a run on a real Android phone.