{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "dynamic-island", "type": "registry:ui", "description": "iOS-style dynamic island component with expandable content and smooth animations", "dependencies": [ "motion" ], "files": [ { "path": "registry/default/ui/dynamic-island.tsx", "content": "\"use client\"\n\nimport React, {\n createContext,\n ReactNode,\n useCallback,\n useContext,\n useEffect,\n useReducer,\n useRef,\n useState,\n} from \"react\"\nimport { AnimatePresence, motion, useWillChange } from \"motion/react\"\n\nconst stiffness = 400\nconst damping = 30\nconst MIN_WIDTH = 691\nconst MAX_HEIGHT_MOBILE_ULTRA = 400\nconst MAX_HEIGHT_MOBILE_MASSIVE = 700\n\nconst min = (a: number, b: number) => (a < b ? a : b)\n\nexport type SizePresets =\n | \"reset\"\n | \"empty\"\n | \"default\"\n | \"compact\"\n | \"compactLong\"\n | \"large\"\n | \"long\"\n | \"minimalLeading\"\n | \"minimalTrailing\"\n | \"compactMedium\"\n | \"medium\"\n | \"tall\"\n | \"ultra\"\n | \"massive\"\n\nconst SIZE_PRESETS = {\n RESET: \"reset\",\n EMPTY: \"empty\",\n DEFAULT: \"default\",\n COMPACT: \"compact\",\n COMPACT_LONG: \"compactLong\",\n LARGE: \"large\",\n LONG: \"long\",\n MINIMAL_LEADING: \"minimalLeading\",\n MINIMAL_TRAILING: \"minimalTrailing\",\n COMPACT_MEDIUM: \"compactMedium\",\n MEDIUM: \"medium\",\n TALL: \"tall\",\n ULTRA: \"ultra\",\n MASSIVE: \"massive\",\n} as const\n\ntype Preset = {\n width: number\n height?: number\n aspectRatio: number\n borderRadius: number\n}\n\nconst DynamicIslandSizePresets: Record = {\n [SIZE_PRESETS.RESET]: {\n width: 150,\n aspectRatio: 1,\n borderRadius: 20,\n },\n [SIZE_PRESETS.EMPTY]: {\n width: 0,\n aspectRatio: 0,\n borderRadius: 0,\n },\n [SIZE_PRESETS.DEFAULT]: {\n width: 150,\n aspectRatio: 44 / 150,\n borderRadius: 46,\n },\n [SIZE_PRESETS.MINIMAL_LEADING]: {\n width: 52.33,\n aspectRatio: 44 / 52.33,\n borderRadius: 22,\n },\n [SIZE_PRESETS.MINIMAL_TRAILING]: {\n width: 52.33,\n aspectRatio: 44 / 52.33,\n borderRadius: 22,\n },\n [SIZE_PRESETS.COMPACT]: {\n width: 235,\n aspectRatio: 44 / 235,\n borderRadius: 46,\n },\n [SIZE_PRESETS.COMPACT_LONG]: {\n width: 300,\n aspectRatio: 44 / 235,\n borderRadius: 46,\n },\n [SIZE_PRESETS.COMPACT_MEDIUM]: {\n width: 351,\n aspectRatio: 64 / 371,\n borderRadius: 44,\n },\n [SIZE_PRESETS.LONG]: {\n width: 371,\n aspectRatio: 84 / 371,\n borderRadius: 42,\n },\n [SIZE_PRESETS.MEDIUM]: {\n width: 371,\n aspectRatio: 210 / 371,\n borderRadius: 22,\n },\n [SIZE_PRESETS.LARGE]: {\n width: 371,\n aspectRatio: 84 / 371,\n borderRadius: 42,\n },\n [SIZE_PRESETS.TALL]: {\n width: 371,\n aspectRatio: 210 / 371,\n borderRadius: 42,\n },\n [SIZE_PRESETS.ULTRA]: {\n width: 630,\n aspectRatio: 630 / 800,\n borderRadius: 42,\n },\n [SIZE_PRESETS.MASSIVE]: {\n width: 891,\n height: 1900,\n aspectRatio: 891 / 891,\n borderRadius: 42,\n },\n}\n\ntype BlobStateType = {\n size: SizePresets\n previousSize: SizePresets | undefined\n animationQueue: Array<{ size: SizePresets; delay: number }>\n isAnimating: boolean\n}\n\ntype BlobAction =\n | { type: \"SET_SIZE\"; newSize: SizePresets }\n | { type: \"INITIALIZE\"; firstState: SizePresets }\n | {\n type: \"SCHEDULE_ANIMATION\"\n animationSteps: Array<{ size: SizePresets; delay: number }>\n }\n | { type: \"ANIMATION_END\" }\n\ntype BlobContextType = {\n state: BlobStateType\n dispatch: React.Dispatch\n setSize: (size: SizePresets) => void\n scheduleAnimation: (\n animationSteps: Array<{ size: SizePresets; delay: number }>\n ) => void\n presets: Record\n}\n\nconst BlobContext = createContext(undefined)\n\nconst blobReducer = (\n state: BlobStateType,\n action: BlobAction\n): BlobStateType => {\n switch (action.type) {\n case \"SET_SIZE\":\n return {\n ...state,\n size: action.newSize,\n previousSize: state.size,\n isAnimating: false, // Only set isAnimating to true if there are more steps\n }\n case \"SCHEDULE_ANIMATION\":\n return {\n ...state,\n animationQueue: action.animationSteps,\n isAnimating: action.animationSteps.length > 0,\n }\n case \"INITIALIZE\":\n return {\n ...state,\n size: action.firstState,\n previousSize: SIZE_PRESETS.EMPTY,\n isAnimating: false,\n }\n case \"ANIMATION_END\":\n return {\n ...state,\n isAnimating: false,\n }\n default:\n return state\n }\n}\n\ninterface DynamicIslandProviderProps {\n children: React.ReactNode\n initialSize?: SizePresets\n initialAnimation?: Array<{ size: SizePresets; delay: number }>\n}\n\nconst DynamicIslandProvider: React.FC = ({\n children,\n initialSize = SIZE_PRESETS.DEFAULT,\n initialAnimation = [],\n}) => {\n const initialState: BlobStateType = {\n size: initialSize,\n previousSize: SIZE_PRESETS.EMPTY,\n animationQueue: initialAnimation,\n isAnimating: initialAnimation.length > 0,\n }\n\n const [state, dispatch] = useReducer(blobReducer, initialState)\n\n useEffect(() => {\n const processQueue = async () => {\n for (const step of state.animationQueue) {\n await new Promise((resolve) => setTimeout(resolve, step.delay))\n dispatch({ type: \"SET_SIZE\", newSize: step.size })\n }\n dispatch({ type: \"ANIMATION_END\" })\n }\n\n if (state.animationQueue.length > 0) {\n processQueue()\n }\n }, [state.animationQueue])\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: needed for dispatch\n const setSize = useCallback(\n (newSize: SizePresets) => {\n if (state.previousSize !== newSize && newSize !== state.size) {\n dispatch({ type: \"SET_SIZE\", newSize })\n }\n },\n [state.previousSize, state.size, dispatch]\n )\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: needed for dispatch\n const scheduleAnimation = useCallback(\n (animationSteps: Array<{ size: SizePresets; delay: number }>) => {\n dispatch({ type: \"SCHEDULE_ANIMATION\", animationSteps })\n },\n [dispatch]\n )\n\n const contextValue = {\n state,\n dispatch,\n setSize,\n scheduleAnimation,\n presets: DynamicIslandSizePresets,\n }\n\n return (\n {children}\n )\n}\n\nconst useDynamicIslandSize = () => {\n const context = useContext(BlobContext)\n if (!context) {\n throw new Error(\n \"useDynamicIslandSize must be used within a DynamicIslandProvider\"\n )\n }\n return context\n}\n\nconst useScheduledAnimations = (\n animations: Array<{ size: SizePresets; delay: number }>\n) => {\n const { scheduleAnimation } = useDynamicIslandSize()\n const animationsRef = useRef(animations)\n\n useEffect(() => {\n scheduleAnimation(animationsRef.current)\n }, [scheduleAnimation])\n}\n\nconst DynamicIslandContainer = ({ children }: { children: ReactNode }) => {\n return (\n
\n {children}\n
\n )\n}\n\nconst DynamicIsland = ({\n children,\n id,\n ...props\n}: {\n children: ReactNode\n id: string\n}) => {\n const willChange = useWillChange()\n const [screenSize, setScreenSize] = useState(\"desktop\")\n\n useEffect(() => {\n const handleResize = () => {\n if (window.innerWidth <= 640) {\n setScreenSize(\"mobile\")\n } else if (window.innerWidth <= 1024) {\n setScreenSize(\"tablet\")\n } else {\n setScreenSize(\"desktop\")\n }\n }\n\n handleResize()\n window.addEventListener(\"resize\", handleResize)\n return () => window.removeEventListener(\"resize\", handleResize)\n }, [])\n\n return (\n \n \n {children}\n \n \n )\n}\n\nconst calculateDimensions = (\n size: SizePresets,\n screenSize: string,\n currentSize: Preset\n): { width: string; height: number } => {\n const isMassiveOnMobile = size === \"massive\" && screenSize === \"mobile\"\n const isUltraOnMobile = size === \"ultra\" && screenSize === \"mobile\"\n\n if (isMassiveOnMobile) {\n return { width: \"350px\", height: MAX_HEIGHT_MOBILE_MASSIVE }\n }\n\n if (isUltraOnMobile) {\n return { width: \"350px\", height: MAX_HEIGHT_MOBILE_ULTRA }\n }\n\n const width = min(currentSize.width, MIN_WIDTH)\n return { width: `${width}px`, height: currentSize.aspectRatio * width }\n}\n\nconst DynamicIslandContent = ({\n children,\n id,\n willChange,\n screenSize,\n ...props\n}: {\n children: React.ReactNode\n id: string\n willChange: any\n screenSize: string\n [key: string]: any\n}) => {\n const { state, presets } = useDynamicIslandSize()\n const currentSize = presets[state.size]\n\n const dimensions = calculateDimensions(state.size, screenSize, currentSize)\n\n return (\n \n {children}\n \n )\n}\n\ntype DynamicContainerProps = {\n className?: string\n children?: React.ReactNode\n}\n\nconst DynamicContainer = ({ className, children }: DynamicContainerProps) => {\n const willChange = useWillChange()\n const { state } = useDynamicIslandSize()\n const { size, previousSize } = state\n\n const isSizeChanged = size !== previousSize\n\n const initialState = {\n opacity: size === previousSize ? 1 : 0,\n scale: size === previousSize ? 1 : 0.9,\n y: size === previousSize ? 0 : 5,\n }\n\n const animateState = {\n opacity: 1,\n scale: 1,\n y: 0,\n }\n\n const transition = {\n type: \"spring\" as const,\n stiffness,\n damping,\n duration: isSizeChanged ? 0.5 : 0.8,\n }\n\n return (\n \n {children}\n \n )\n}\n\ntype DynamicChildrenProps = {\n className?: string\n children?: React.ReactNode\n}\n\nconst DynamicDiv = ({ className, children }: DynamicChildrenProps) => {\n const { state } = useDynamicIslandSize()\n const { size, previousSize } = state\n const willChange = useWillChange()\n\n return (\n \n {children}\n \n )\n}\n\ntype MotionProps = {\n className: string\n children: React.ReactNode\n}\n\nconst DynamicTitle = ({ className, children }: MotionProps) => {\n const { state } = useDynamicIslandSize()\n const { size, previousSize } = state\n const willChange = useWillChange()\n\n return (\n \n {children}\n \n )\n}\n\nconst DynamicDescription = ({ className, children }: MotionProps) => {\n const { state } = useDynamicIslandSize()\n const { size, previousSize } = state\n const willChange = useWillChange()\n\n return (\n \n {children}\n \n )\n}\n\nexport {\n DynamicContainer,\n DynamicTitle,\n DynamicDescription,\n DynamicIsland,\n SIZE_PRESETS,\n stiffness,\n DynamicDiv,\n damping,\n DynamicIslandSizePresets,\n BlobContext,\n useDynamicIslandSize,\n useScheduledAnimations,\n DynamicIslandProvider,\n}\n\nexport default DynamicIsland\n", "type": "registry:ui" } ] }