Components
DynamicNotification
ExperimentalExperimental — the contract and the rendering can change without notice. Own the installed copy before shipping it.Dynamic Island-style AI notification pill with a thinking state that morphs into a reply bubble — token-driven surface, WebGL-enhanced backdrop blur, or real refractive liquid glass.
Version
Composition
Notification with liquid backdrop
- <DynamicNotification>
- <DynamicNotificationIsland>
- <DynamicNotificationLiquid />
- <DynamicNotificationPill>
- <DynamicNotificationIndicator />
- <DynamicNotificationContent>
- <DynamicNotificationTitle />
- <DynamicNotificationClose />
- <DynamicNotificationMessage />
- <DynamicNotificationReply>
- <DynamicNotificationReplyInput />
- <DynamicNotificationReplySubmit />
- <DynamicNotificationIsland>
Glass backdrop alternative
Use Glass or Liquid as the backdrop; omit both for the CSS surface.
- <DynamicNotification>
- <DynamicNotificationIsland>
- <DynamicNotificationGlass />
- <DynamicNotificationPill>
- <DynamicNotificationIndicator />
- <DynamicNotificationContent>
- <DynamicNotificationMessage />
- <DynamicNotificationIsland>
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
Every version ships from this one registry item, so the install command is identical — the picker only swaps the documented composition; switching versions later is a call-site change, not a reinstall.
npx shadcn@latest add https://control-ui.dev/r/dynamic-notification.jsonUsage
"use client";import type { MastraDBMessage } from "@mastra/core/agent/message-list";import { useChat } from "@mastra/react";import { DynamicNotification, DynamicNotificationContent, DynamicNotificationGlass, DynamicNotificationIndicator, DynamicNotificationIsland, DynamicNotificationMessage, DynamicNotificationPill, DynamicNotificationReply, DynamicNotificationReplyInput, DynamicNotificationReplySubmit,} from "@/components/control-ui/dynamic-notification";function latestAssistantText(messages: MastraDBMessage[]) { const message = messages.findLast((entry) => entry.role === "assistant"); return message?.content.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("") ?? "No reply yet";}export function Example({ agentId }: { agentId: string }) { const { isRunning, messages, sendMessage } = useChat({ agentId }); return ( <DynamicNotification variant="glass" loading={isRunning} onReply={async ({ value, clear }) => { await sendMessage({ message: value }); clear(); }} > <DynamicNotificationIsland> <DynamicNotificationGlass /> <DynamicNotificationPill> <DynamicNotificationIndicator /> Assistant </DynamicNotificationPill> <DynamicNotificationContent> <DynamicNotificationMessage>{latestAssistantText(messages)}</DynamicNotificationMessage> <DynamicNotificationReply> <DynamicNotificationReplyInput placeholder="Answer…" /> <DynamicNotificationReplySubmit /> </DynamicNotificationReply> </DynamicNotificationContent> </DynamicNotificationIsland> </DynamicNotification> );}Dependencies
Behavior hook
src/registry/hooks/use-dynamic-notification.tsHookBackdrop-blur WebGL engine
src/registry/sources/control-ui/dynamic-notification-glass.tsSupportRefractive liquid WebGL engine
src/registry/sources/control-ui/dynamic-notification-liquid.tsSupportDynamic notification siri wave
src/registry/sources/control-ui/dynamic-notification-siri-wave.tsSupportLiquid glass optics
Buttonsrc/registry/lib/liquid-glass-optics.tsUtilbuttonPrimitiveRaw code
Owned source and private support files shared by every version
"use client";import type { ChangeEvent, ComponentProps, CSSProperties, KeyboardEvent, MouseEvent } from "react";import { createContext, useContext, useEffect, useId, useMemo, useRef } from "react";import type { FormSubmitEvent } from "@/components/control-ui/control-props";import { createDynamicNotificationGlass } from "@/components/control-ui/dynamic-notification-glass";import { createDynamicNotificationLiquid } from "@/components/control-ui/dynamic-notification-liquid";import type { DynamicNotificationProps, DynamicNotificationVariant } from "@/components/control-ui/hooks/use-dynamic-notification";import { type DynamicNotificationController, useDynamicNotification } from "@/components/control-ui/hooks/use-dynamic-notification";import type { DynamicNotificationKnobStyle } from "@/components/control-ui/knob-contracts/dynamic-notification-knobs";import { cn } from "@/components/control-ui/lib/cn";import { Button } from "@/components/control-ui/ui/button";export type DynamicNotificationState = "collapsed" | "thinking" | "expanded";type DynamicNotificationWordStyle = CSSProperties & { "--_dynamic-notification-word-index"?: string };type DynamicNotificationShellContextValue = Pick<DynamicNotificationController, "open" | "disabled" | "setOpen"> & { state: DynamicNotificationState; variant: DynamicNotificationVariant; contentId: string;};type DynamicNotificationReplyContextValue = Pick< DynamicNotificationController, "reply" | "setReply" | "normalizedReply" | "canSubmit" | "clear" | "submitReply" | "handleReplySubmit">;const DynamicNotificationShellContext = createContext<DynamicNotificationShellContextValue | null>(null);const DynamicNotificationReplyContext = createContext<DynamicNotificationReplyContextValue | null>(null);function resolveNotificationState(open: boolean, loading: boolean): DynamicNotificationState { if (!open) return "collapsed"; if (loading) return "thinking"; return "expanded";}function useDynamicNotificationShellContext() { const context = useContext(DynamicNotificationShellContext); if (!context) throw new Error("DynamicNotification compound components must be rendered inside <DynamicNotification>."); return context;}function useDynamicNotificationReplyContext() { const context = useContext(DynamicNotificationReplyContext); if (!context) throw new Error("DynamicNotification reply components must be rendered inside <DynamicNotification>."); return context;}export function useDynamicNotificationContext() { const shell = useDynamicNotificationShellContext(); const reply = useDynamicNotificationReplyContext(); return { ...shell, ...reply };}export function DynamicNotification({ open, defaultOpen, onOpenChange, loading = false, replyValue, defaultReplyValue, onReplyValueChange, onReply, variant = "surface", disabled = false, className, children, ...props}: DynamicNotificationProps) { const notification = useDynamicNotification({ open, defaultOpen, onOpenChange, replyValue, defaultReplyValue, onReplyValueChange, onReply, disabled, }); const contentId = useId(); const state = resolveNotificationState(notification.open, loading); const shellContext = useMemo( () => ({ open: notification.open, disabled: notification.disabled, setOpen: notification.setOpen, state, variant, contentId, }) satisfies DynamicNotificationShellContextValue, [notification.open, notification.disabled, notification.setOpen, state, variant, contentId], ); const replyContext = useMemo( () => ({ reply: notification.reply, setReply: notification.setReply, normalizedReply: notification.normalizedReply, canSubmit: notification.canSubmit, clear: notification.clear, submitReply: notification.submitReply, handleReplySubmit: notification.handleReplySubmit, }) satisfies DynamicNotificationReplyContextValue, [ notification.reply, notification.setReply, notification.normalizedReply, notification.canSubmit, notification.clear, notification.submitReply, notification.handleReplySubmit, ], ); return ( <DynamicNotificationShellContext.Provider value={shellContext}> <DynamicNotificationReplyContext.Provider value={replyContext}> <div data-control-ui="dynamic-notification" data-control-family="dynamic-notification" data-slot="root" data-state={state} data-variant={variant} className={cn("relative flex w-full justify-center", className)} {...props} > {children} </div> </DynamicNotificationReplyContext.Provider> </DynamicNotificationShellContext.Provider> );}export type DynamicNotificationIslandProps = ComponentProps<"section"> & { style?: CSSProperties & DynamicNotificationKnobStyle };export function DynamicNotificationIsland({ className, onKeyDown, ...props }: DynamicNotificationIslandProps) { const { open, setOpen, state, variant } = useDynamicNotificationShellContext(); function handleKeyDown(event: KeyboardEvent<HTMLElement>) { onKeyDown?.(event); if (event.defaultPrevented) return; if (event.key === "Escape" && open) { setOpen(false, "escape-key", event.nativeEvent, event.currentTarget); } } return ( <section aria-label="Assistant notification" aria-busy={state === "thinking" || undefined} data-control-ui="dynamic-notification" data-control-family="dynamic-notification" data-slot="island" data-state={state} data-variant={variant} onKeyDown={handleKeyDown} className={cn("relative isolate overflow-hidden", className)} {...props} /> );}export type DynamicNotificationGlassProps = ComponentProps<"canvas"> & { style?: CSSProperties & DynamicNotificationKnobStyle };/** Optional upgrade for variant="glass" — CSS fallback stays underneath. */export function DynamicNotificationGlass({ className, ...props }: DynamicNotificationGlassProps) { const canvasRef = useRef<HTMLCanvasElement | null>(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; return createDynamicNotificationGlass(canvas); }, []); return ( <canvas ref={canvasRef} aria-hidden="true" tabIndex={-1} data-control-ui="dynamic-notification" data-control-family="dynamic-notification" data-slot="glass" className={cn("pointer-events-none absolute inset-0 -z-10 size-full", className)} {...props} /> );}export type DynamicNotificationLiquidProps = ComponentProps<"canvas"> & { style?: CSSProperties & DynamicNotificationKnobStyle };/** WebGL transmits nearest scene through surface while keeping distortion concentrated at its edge. */export function DynamicNotificationLiquid({ className, ...props }: DynamicNotificationLiquidProps) { const canvasRef = useRef<HTMLCanvasElement | null>(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; return createDynamicNotificationLiquid(canvas); }, []); return ( <canvas ref={canvasRef} aria-hidden="true" tabIndex={-1} data-control-ui="dynamic-notification" data-control-family="dynamic-notification" data-slot="liquid" className={cn("pointer-events-none absolute inset-0 -z-10 size-full", className)} {...props} /> );}export type DynamicNotificationPillProps = ComponentProps<"button"> & { style?: CSSProperties & DynamicNotificationKnobStyle };export function DynamicNotificationPill({ className, children, onClick, ...props }: DynamicNotificationPillProps) { const { contentId, disabled, open, setOpen } = useDynamicNotificationShellContext(); function handleClick(event: MouseEvent<HTMLButtonElement>) { onClick?.(event); if (event.defaultPrevented) return; setOpen(true, "trigger-press", event.nativeEvent, event.currentTarget); } return ( <button type="button" aria-expanded={open} aria-controls={contentId} // inert, not CSS visibility: it lands with React commit, so tab order is right mid-morph instead of flipping halfway through inert={open} data-control-ui="dynamic-notification" data-control-family="dynamic-notification" data-slot="pill" onClick={handleClick} disabled={disabled} className={cn("absolute inset-0 flex cursor-pointer items-center justify-center", className)} {...props} > {children} </button> );}export type DynamicNotificationIndicatorProps = ComponentProps<"span"> & { style?: CSSProperties & DynamicNotificationKnobStyle };/** Breathing orb marking assistant activity; dynamic-notification.css animates it. */export function DynamicNotificationIndicator({ className, ...props }: DynamicNotificationIndicatorProps) { return ( <span aria-hidden="true" data-control-ui="dynamic-notification" data-control-family="dynamic-notification" data-slot="indicator" className={cn("shrink-0", className)} {...props} /> );}export type DynamicNotificationContentProps = ComponentProps<"div"> & { style?: CSSProperties & DynamicNotificationKnobStyle };export function DynamicNotificationContent({ className, id, ...props }: DynamicNotificationContentProps) { const { contentId, state } = useDynamicNotificationShellContext(); return ( <div id={id ?? contentId} inert={state !== "expanded"} data-control-ui="dynamic-notification" data-control-family="dynamic-notification" data-slot="content" className={cn("flex flex-col", className)} {...props} /> );}export type DynamicNotificationTitleProps = ComponentProps<"div"> & { style?: CSSProperties & DynamicNotificationKnobStyle };export function DynamicNotificationTitle({ className, ...props }: DynamicNotificationTitleProps) { return ( <div data-control-ui="dynamic-notification" data-control-family="dynamic-notification" data-slot="title" className={cn("flex-1", className)} {...props} /> );}export type DynamicNotificationMessageProps = Omit<ComponentProps<"p">, "children"> & { children: string;} & { style?: CSSProperties & DynamicNotificationKnobStyle };export function DynamicNotificationMessage({ className, children, ...props }: DynamicNotificationMessageProps) { return ( <p aria-live="polite" data-control-ui="dynamic-notification" data-control-family="dynamic-notification" data-slot="message" className={className} {...props} > <DynamicNotificationWords key={children} text={children} /> </p> );}function DynamicNotificationWords({ text }: { text: string }) { let wordIndex = 0; return text.split(/(\s+)/).map((part, position) => { if (part.length === 0 || /^\s+$/.test(part)) return part; const style: DynamicNotificationWordStyle = { "--_dynamic-notification-word-index": `${wordIndex}` }; wordIndex += 1; return ( // biome-ignore lint/suspicious/noArrayIndexKey: split positions are stable for a given text; the list remounts wholesale (key={text}) when the message changes. <span key={position} data-control-ui="dynamic-notification" data-control-family="dynamic-notification" data-slot="word" style={style}> {part} </span> ); });}export type DynamicNotificationReplyProps = ComponentProps<"form"> & { style?: CSSProperties & DynamicNotificationKnobStyle };export function DynamicNotificationReply({ className, onSubmit, ...props }: DynamicNotificationReplyProps) { const { handleReplySubmit } = useDynamicNotificationReplyContext(); function handleSubmit(event: FormSubmitEvent) { onSubmit?.(event); if (event.defaultPrevented) return; handleReplySubmit(event); } return ( <form data-control-ui="dynamic-notification" data-control-family="dynamic-notification" data-slot="reply" onSubmit={handleSubmit} className={cn("flex items-center", className)} {...props} /> );}export type DynamicNotificationReplyInputProps = ComponentProps<"input"> & { style?: CSSProperties & DynamicNotificationKnobStyle };export function DynamicNotificationReplyInput({ className, onChange, disabled, ...props }: DynamicNotificationReplyInputProps) { const { disabled: contextDisabled, state } = useDynamicNotificationShellContext(); const { reply, setReply } = useDynamicNotificationReplyContext(); const inputRef = useRef<HTMLInputElement | null>(null); // waits out the "thinking" phase — content is inert until expanded, so focus would be dropped useEffect(() => { if (state !== "expanded") return; const frame = requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true })); return () => cancelAnimationFrame(frame); }, [state]); function handleChange(event: ChangeEvent<HTMLInputElement>) { onChange?.(event); if (!event.defaultPrevented) setReply(event.currentTarget.value); } return ( <input ref={inputRef} type="text" aria-label="Reply" data-control-ui="dynamic-notification" data-control-family="dynamic-notification" data-slot="reply-input" value={reply} onChange={handleChange} disabled={disabled ?? contextDisabled} className={cn("min-w-0 flex-1 disabled:cursor-not-allowed", className)} {...props} /> );}export type DynamicNotificationReplySubmitProps = ComponentProps<typeof Button>;export function DynamicNotificationReplySubmit({ className, disabled, children, ...props }: DynamicNotificationReplySubmitProps) { const { canSubmit } = useDynamicNotificationReplyContext(); return ( <Button data-control-ui="dynamic-notification" data-dynamic-notification-submit="true" data-slot="reply-submit" type="submit" variant="solid" size="lg" iconOnly shape="circle" aria-label="Send reply" disabled={disabled ?? !canSubmit} className={cn("shrink-0", className)} {...props} > {children ?? ( <svg viewBox="0 0 16 16" className="size-4" aria-hidden="true" fill="none"> <path d="M8 12.5v-9M4 7l4-3.5L12 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /> </svg> )} </Button> );}export type DynamicNotificationCloseProps = ComponentProps<typeof Button>;export function DynamicNotificationClose({ className, children, onClick, ...props }: DynamicNotificationCloseProps) { const { setOpen } = useDynamicNotificationShellContext(); function handleClick(event: MouseEvent<HTMLButtonElement>) { onClick?.(event); if (event.defaultPrevented) return; setOpen(false, "close-press", event.nativeEvent, event.currentTarget); } return ( <Button data-control-ui="dynamic-notification" data-dynamic-notification-close="true" data-slot="close" variant="quiet" size="sm" iconOnly shape="circle" aria-label="Dismiss" onClick={handleClick} className={cn("shrink-0", className)} {...props} > {children ?? ( <svg viewBox="0 0 16 16" className="size-3.5" aria-hidden="true" fill="none"> <path d="M4 4 12 12M12 4 4 12" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /> </svg> )} </Button> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-dynamic-notification-* · 13 knobsHow the cascade resolves--cui-dynamic-notification-content-easing*linear(
0,
0.0553 4.55%,
0.1795 9.09%,
0.328 13.64%,
0.4745 18.18%,
0.6054 22.73%,
0.7148 27.27%,
0.8017 31.82%,
0.868 36.36%,
0.9166 40.91%,
0.9509 45.45%,
0.9741 50%,
0.9891 54.55%,
0.9982 59.09%,
1.0032 63.64%,
1.0056 68.18%,
1.0063 72.73%,
1.006 77.27%,
1.0053 81.82%,
1.0044 86.36%,
1.0034 90.91%,
1.0026 95.45%,
1
)--cui-dynamic-notification-expanded-radius<length>1.65rem--cui-dynamic-notification-morph-easing*linear(
0,
0.0568 3.85%,
0.1888 7.69%,
0.351 11.54%,
0.5142 15.38%,
0.6608 19.23%,
0.7824 23.08%,
0.8767 26.92%,
0.9449 30.77%,
0.9908 34.62%,
1.0187 38.46%,
1.0332 42.31%,
1.0382 46.15%,
1.0372 50%,
1.0327 53.85%,
1.0266 57.69%,
1.0203 61.54%,
1.0145 65.38%,
1.0095 69.23%,
1.0056 73.08%,
1.0027 76.92%,
1.0008 80.77%,
0.9995 84.62%,
0.9988 88.46%,
0.9986 92.31%,
0.9985 96.15%,
1
)--cui-dynamic-notification-glass-foreground<color>white--cui-dynamic-notification-glass-ring-color<color>oklch(1 0 0 / 0.12)--cui-dynamic-notification-liquid-foreground<color>white--cui-dynamic-notification-surface-background<color>var(--popover)--cui-dynamic-notification-surface-foreground<color>var(--popover-foreground)--cui-dynamic-notification-surface-ring-color<color>var(--border)--cui-dynamic-notification-surface-shadow*var(--shadow-pop)--cui-dynamic-notification-indicator-end<color>oklch(0.7 0.17 250)--cui-dynamic-notification-indicator-middle<color>oklch(0.75 0.15 70)--cui-dynamic-notification-indicator-start<color>oklch(0.62 0.19 25)