Skip to content
pixelfor Expo
Ctrl K

Disclosure

Dynamic Island

A compact pill expands into measured call, music and timer views

Try it on your phone

Dynamic Island

Scan to open Dynamic Island 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

Be UI

Our Expo component

React Native Web

Component 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.

Back to Be UI