{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "native-notch-shadcnui", "type": "registry:component", "title": "Native Notch", "description": "Dynamic Island-inspired notch component with smooth spring animations, draggable physics, and expandable content area", "dependencies": [ "framer-motion", "react" ], "files": [ { "path": "@uitripled/react-shadcn/src/components/native/native-notch-shadcnui.tsx", "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n AnimatePresence,\n animate,\n motion,\n MotionConfig,\n useMotionValue,\n useReducedMotion,\n useSpring,\n useTransform,\n} from \"framer-motion\";\nimport { X } from \"lucide-react\";\nimport type React from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\n\n/**\n * Size variants for the notch component\n */\nexport type NotchSize = \"sm\" | \"md\" | \"lg\";\n\n/**\n * Position configuration for initial placement\n */\nexport interface NotchPosition {\n /**\n * Top offset in pixels\n */\n top?: number;\n /**\n * Bottom offset in pixels\n */\n bottom?: number;\n /**\n * Horizontal alignment\n */\n align?: \"left\" | \"center\" | \"right\";\n}\n\nexport interface NativeNotchProps {\n /**\n * Content to display when the notch is open\n */\n children?: React.ReactNode;\n /**\n * Custom content/icon to display when the notch is closed\n */\n collapsedIcon?: React.ReactNode;\n /**\n * Size variant\n * @default \"md\"\n */\n size?: NotchSize;\n /**\n * Initial position configuration\n */\n position?: NotchPosition;\n /**\n * Whether the notch is draggable\n * @default true\n */\n draggable?: boolean;\n /**\n * Default expanded state\n * @default false\n */\n defaultExpanded?: boolean;\n /**\n * Controlled expanded state\n */\n expanded?: boolean;\n /**\n * Callback when expanded state changes\n */\n onExpandedChange?: (expanded: boolean) => void;\n /**\n * Callback when notch is clicked\n */\n onClick?: (e: React.MouseEvent) => void;\n /**\n * Additional CSS classes\n */\n className?: string;\n}\n\nconst sizeVariants = {\n sm: {\n collapsed: { width: 40, height: 40, radius: 20 },\n expanded: { width: 280, height: 160, radius: 24 },\n },\n md: {\n collapsed: { width: 48, height: 48, radius: 24 },\n expanded: { width: 340, height: 200, radius: 28 },\n },\n lg: {\n collapsed: { width: 56, height: 56, radius: 28 },\n expanded: { width: 400, height: 240, radius: 32 },\n },\n};\n\nconst positionStyles = {\n left: \"left-8\",\n center: \"left-1/2 -translate-x-1/2\",\n right: \"right-8\",\n};\n\nexport function NativeNotch({\n children,\n collapsedIcon,\n size = \"md\",\n position = { top: 32, align: \"center\" },\n draggable = true,\n defaultExpanded = false,\n expanded: controlledExpanded,\n onExpandedChange,\n onClick,\n className,\n}: NativeNotchProps) {\n const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);\n const [isDragging, setIsDragging] = useState(false);\n const notchRef = useRef(null);\n const closeButtonRef = useRef(null);\n const wasExpanded = useRef(false);\n const reduce = useReducedMotion() ?? false;\n\n const isControlled = controlledExpanded !== undefined;\n const isExpanded = isControlled ? controlledExpanded : internalExpanded;\n\n const setExpanded = (value: boolean) => {\n if (!isControlled) {\n setInternalExpanded(value);\n }\n onExpandedChange?.(value);\n };\n\n const sizeConfig = sizeVariants[size];\n\n // Motion values for drag physics\n const x = useMotionValue(0);\n const y = useMotionValue(0);\n\n const springConfig = { stiffness: 150, damping: 20, mass: 0.8 };\n const springX = useSpring(x, springConfig);\n const springY = useSpring(y, springConfig);\n\n // Rotation and scale based on drag velocity/position\n const rotate = useTransform(springX, [-200, 200], [-5, 5]);\n const scale = useTransform(\n [springX, springY],\n ([latestX, latestY]: number[]) => {\n const distance = Math.sqrt(latestX * latestX + latestY * latestY);\n return 1 - Math.min(distance / 1500, 0.08);\n }\n );\n\n // Motion values for dimensions\n const notchWidth = useMotionValue(sizeConfig.collapsed.width);\n const notchHeight = useMotionValue(sizeConfig.collapsed.height);\n const notchRadius = useMotionValue(sizeConfig.collapsed.radius);\n\n const springWidth = useSpring(notchWidth, { stiffness: 400, damping: 35, mass: 0.6 });\n const springHeight = useSpring(notchHeight, { stiffness: 300, damping: 30, mass: 0.8 });\n const springRadius = useSpring(notchRadius, { stiffness: 350, damping: 30, mass: 0.6 });\n\n useEffect(() => {\n // Reduced motion: snap dimensions instantly, no staged choreography.\n if (reduce) {\n const target = isExpanded ? sizeConfig.expanded : sizeConfig.collapsed;\n notchWidth.jump(target.width);\n notchHeight.jump(target.height);\n notchRadius.jump(target.radius);\n return;\n }\n if (isExpanded) {\n animate(notchWidth, sizeConfig.expanded.width, { type: \"spring\", stiffness: 400, damping: 35, mass: 0.5 });\n animate(notchRadius, sizeConfig.expanded.radius, { type: \"spring\", stiffness: 350, damping: 30, mass: 0.5 });\n const timeout = setTimeout(() => {\n animate(notchHeight, sizeConfig.expanded.height, { type: \"spring\", stiffness: 300, damping: 28, mass: 0.6 });\n }, 80);\n return () => clearTimeout(timeout);\n } else {\n animate(notchHeight, sizeConfig.collapsed.height, { type: \"spring\", stiffness: 350, damping: 30, mass: 0.5 });\n const timeout = setTimeout(() => {\n animate(notchWidth, sizeConfig.collapsed.width, { type: \"spring\", stiffness: 400, damping: 35, mass: 0.5 });\n animate(notchRadius, sizeConfig.collapsed.radius, { type: \"spring\", stiffness: 350, damping: 30, mass: 0.5 });\n }, 60);\n return () => clearTimeout(timeout);\n }\n }, [isExpanded, notchWidth, notchHeight, notchRadius, sizeConfig, reduce]);\n\n // Move focus into the panel on expand, back to the notch on collapse.\n useEffect(() => {\n if (isExpanded) {\n wasExpanded.current = true;\n closeButtonRef.current?.focus();\n } else if (wasExpanded.current) {\n notchRef.current?.focus();\n }\n }, [isExpanded]);\n\n const handlePointerDown = (e: React.PointerEvent) => {\n if (isExpanded || !draggable) return;\n setIsDragging(true);\n (e.target as HTMLElement).setPointerCapture(e.pointerId);\n };\n\n const handlePointerMove = (e: React.PointerEvent) => {\n if (!isDragging || isExpanded || !draggable) return;\n\n const centerX = window.innerWidth / 2;\n const centerY = (position.top || 32) + 24;\n\n const newX = e.clientX - centerX;\n const newY = e.clientY - centerY;\n\n const maxX = window.innerWidth / 2 - 40;\n const maxY = window.innerHeight - 80;\n const minY = -20;\n\n x.set(Math.max(-maxX, Math.min(maxX, newX)));\n y.set(Math.max(minY, Math.min(maxY, newY)));\n };\n\n const handlePointerUp = (e: React.PointerEvent) => {\n if (!isDragging) return;\n setIsDragging(false);\n (e.target as HTMLElement).releasePointerCapture(e.pointerId);\n\n // Snap back\n animate(x, 0, { type: \"spring\", stiffness: 200, damping: 25 });\n animate(y, 0, { type: \"spring\", stiffness: 200, damping: 25 });\n };\n\n const handleClick = (e: React.MouseEvent) => {\n if (isDragging) return;\n if (Math.abs(x.get()) > 5 || Math.abs(y.get()) > 5) return;\n\n onClick?.(e);\n if (!isExpanded) {\n setExpanded(true);\n }\n };\n\n return (\n \n {\n if (!isExpanded && (e.key === \"Enter\" || e.key === \" \")) {\n e.preventDefault();\n setExpanded(true);\n }\n if (isExpanded && e.key === \"Escape\") {\n setExpanded(false);\n }\n }}\n >\n \n \n {!isExpanded ? (\n \n {collapsedIcon ||
}\n \n ) : (\n \n
\n {\n e.stopPropagation();\n setExpanded(false);\n }}\n className=\"relative p-1 rounded-full hover:bg-accent/10 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring after:absolute after:-inset-2\"\n >\n \n \n
\n \n {children}\n \n \n )}\n \n \n \n \n );\n}\n", "type": "registry:component", "target": "components/uitripled/native-notch-shadcnui.tsx" } ] }