morphiq
componentdocksource: ok · hit
Magnifying Dock
A dock whose items swell as the pointer nears, on springs. Distance drives the scale, so the row breathes rather than one icon popping.
npx shadcn add "https://morphiq.prathampatel.design/r/dock.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationframer-motiongsap
Files5 · source cached 10/15/2026, 9:53:38 AM
src/components/Dock/index.js
export { default } from './Dock.jsx'
src/components/Dock/Dock.jsx
'use client'
import React, { useState, useRef, useCallback } from 'react'
import { motion, useMotionValue, useTransform, useSpring } from 'framer-motion'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
import { HomeIcon, BellIcon, SearchIcon, SettingsIcon, UserIcon } from './Icons.jsx'
import './Dock.css'
gsap.registerPlugin(useGSAP)
/* The default row, used when no `items` are passed — a dock with nothing in it
is not a useful default, and these five are what the demos show. Pass your
own to replace them; the shape is { id, label, Icon }. */
const DOCK_ITEMS = [
{ id: 'home', label: 'Home', Icon: HomeIcon },
{ id: 'bell', label: 'Notifications', Icon: BellIcon },
{ id: 'search', label: 'Search', Icon: SearchIcon },
{ id: 'settings', label: 'Settings', Icon: SettingsIcon },
{ id: 'user', label: 'Profile', Icon: UserIcon },
]
const DISTANCE_RANGE = 130
const BASE_SIZE = 36 // Baseline item width/height (36px x 36px)
const MAGNIFIED_SIZE = 54 // Magnified item width/height (54px x 54px)
const MAX_Y = -14 // Upward elevation offset
/* Standalone palette. These are the component's own defaults — deliberately
not tied to any host theme, so an installed copy looks right on day one.
Pass the props to match your own design; the dock writes them as custom
properties and the stylesheet reads them from there. */
const SURFACE = '#08101e' // dock bar and item fill
const BORDER = '#142544' // bar and tooltip hairline
const ITEM_BORDER = '#0c1f43' // item hairline, at rest
const ACCENT = '#639aff' // active item and backlight glow
const ICON = '#ffffff' // glyphs and tooltip text
/* =========================================================
DockItem Component (100% Opacity Tooltip + Slide-In & Fade Out)
========================================================= */
function DockItem({ item, index, mouseX, activeIndex, onClick, baseSize, magnifiedSize }) {
const { Icon, label, id } = item
const itemRef = useRef(null)
const isActive = activeIndex === index
// Calculate distance from mouse clientX to item center
const distance = useTransform(mouseX, (val) => {
const bounds = itemRef.current?.getBoundingClientRect()
if (!bounds || val === Infinity) return Infinity
return val - (bounds.x + bounds.width / 2)
})
// Dynamic button size (1:1 square scaling from 36px to 54px)
const sizeSync = useTransform(
distance,
[-DISTANCE_RANGE, 0, DISTANCE_RANGE],
[baseSize, magnifiedSize, baseSize]
)
// Dynamic upward elevation
const ySync = useTransform(
distance,
[-DISTANCE_RANGE, 0, DISTANCE_RANGE],
[0, MAX_Y, 0]
)
// Tooltip opacity: 100% (1.0) while hovering over/near the button, 0% when moving out
const tooltipOpacitySync = useTransform(distance, (d) => {
return Math.abs(d) <= 24 ? 1 : 0
})
// Tooltip slide offset: 0px when hovered (at 100% opacity), +10px when moving out (slides down & fades out)
const tooltipYOffsetSync = useTransform(distance, (d) => {
return Math.abs(d) <= 24 ? 0 : 10
})
// Responsive spring physics
const size = useSpring(sizeSync, { mass: 0.1, stiffness: 200, damping: 15 })
const y = useSpring(ySync, { mass: 0.1, stiffness: 200, damping: 15 })
const tooltipOpacity = useSpring(tooltipOpacitySync, { mass: 0.1, stiffness: 300, damping: 20 })
const tooltipYOffset = useSpring(tooltipYOffsetSync, { mass: 0.1, stiffness: 280, damping: 20 })
// Combine button elevation + tooltip slide offset
const tooltipY = useTransform([y, tooltipYOffset], ([latestY, latestOffset]) => latestY + latestOffset)
// Active micro-ripple effect via GSAP on click
useGSAP(() => {
if (!isActive || !itemRef.current) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
gsap.fromTo(
itemRef.current,
{ boxShadow: '0 0 0 0 rgba(34, 34, 34, 0.4)' },
{
boxShadow: '0 0 0 8px rgba(34, 34, 34, 0)',
duration: 0.6,
ease: 'power2.out',
}
)
}, [isActive])
return (
<div className="dock-item-container">
{/* Tooltip Label - stays at 100% opacity on hover, slides in from bottom, completely fades out on leave */}
<motion.div
className="dock-tooltip"
style={{
opacity: tooltipOpacity,
x: '-50%',
y: tooltipY,
}}
>
{label}
</motion.div>
{/* Button container scaling & elevating */}
<motion.button
ref={itemRef}
className={`dock-item${isActive ? ' dock-item--active' : ''}`}
style={{
width: size,
height: size,
y: y,
}}
onClick={() => onClick(index)}
/* Keyboard focus stands in for the pointer. Magnification and the
tooltip are both driven by `mouseX`, so pinning it to this item's
own centre gives a keyboard user the same swell and the same label
a mouse user gets, through the existing machinery rather than a
second code path. Pointer-induced focus is skipped, since the
pointer is already driving mouseX. */
onFocus={(e) => {
if (!e.target.matches(':focus-visible')) return
const r = itemRef.current?.getBoundingClientRect()
if (r) mouseX.set(r.x + r.width / 2)
}}
onBlur={() => mouseX.set(Infinity)}
whileTap={{ scale: 0.92 }}
aria-label={label}
aria-pressed={isActive}
id={`dock-item-${id}`}
>
<Icon size={24} color="currentColor" />
</motion.button>
</div>
)
}
/* =========================================================
Main Dock Container
========================================================= */
export default function Dock({
/* Content was a module constant, so changing the icons meant editing the
copied source, and `activeIndex` was internal state with no way out — the
host could neither set what was selected nor learn that it had changed. */
items = DOCK_ITEMS,
activeId, // controlled selection, by id
defaultActiveId = null, // uncontrolled starting selection
onSelect, // (id, item, index) — fires on every activation
baseSize = BASE_SIZE,
magnifiedSize = MAGNIFIED_SIZE,
// --- paint ---
surface = SURFACE,
border = BORDER,
itemBorder = ITEM_BORDER,
accent = ACCENT,
icon = ICON,
'aria-label': ariaLabel = 'Dock',
/* The only component in the library that forwarded none of these. Without
them the dock is position: fixed with no way to place it anywhere else. */
className = '',
style,
...rest
}) {
const mouseX = useMotionValue(Infinity)
const dockRef = useRef(null)
/* Controlled when `activeId` is given, uncontrolled otherwise — the usual
React contract, so the host can drive it or ignore it. */
const [ownId, setOwnId] = useState(defaultActiveId)
const controlled = activeId !== undefined
const currentId = controlled ? activeId : ownId
const activeIndex = items.findIndex((i) => i.id === currentId)
const handleClick = useCallback(
(idx) => {
const item = items[idx]
if (!item) return
const next = item.id === currentId ? null : item.id
if (!controlled) setOwnId(next)
onSelect?.(next, next === null ? null : item, next === null ? -1 : idx)
},
[items, currentId, controlled, onSelect]
)
// Initial Entrance Animation using GSAP
useGSAP(() => {
if (!dockRef.current) return
// The dock is chrome, not content: with reduced motion it is simply there.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const items = dockRef.current.querySelectorAll('.dock-item-container')
gsap.from(items, {
y: 50,
opacity: 0,
stagger: 0.06,
duration: 0.6,
ease: 'back.out(1.5)',
delay: 0.1,
})
gsap.from(dockRef.current, {
y: 30,
opacity: 0,
duration: 0.5,
ease: 'power3.out',
})
}, { scope: dockRef })
return (
<div
className={`dock-container ${className}`.trim()}
ref={dockRef}
style={{
'--dock-surface': surface,
'--dock-border': border,
'--dock-item-border': itemBorder,
'--dock-accent': accent,
'--dock-icon': icon,
...style,
}}
{...rest}
>
{/* Ambient Backlight Glow */}
<div className="dock-glow" />
<motion.div
className="dock"
/* `group`, not `toolbar`: toolbar carries an arrow-key contract this
does not implement, and claiming a role you do not honour is worse
than claiming none. */
role="group"
aria-label={ariaLabel}
onMouseMove={(e) => mouseX.set(e.clientX)}
onMouseLeave={() => mouseX.set(Infinity)}
>
{items.map((item, idx) => (
<DockItem
key={item.id}
item={item}
index={idx}
mouseX={mouseX}
activeIndex={activeIndex}
onClick={handleClick}
baseSize={baseSize}
magnifiedSize={magnifiedSize}
/>
))}
</motion.div>
</div>
)
}
src/components/Dock/Icons.jsx
import React from 'react'
export const HomeIcon = ({ size = 24, color = '#ffffff' }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 10.25V20C3 20.5523 3.44772 21 4 21H9V15C9 14.4477 9.44772 14 10 14H14C14.5523 14 15 14.4477 15 15V21H20C20.5523 21 21 20.5523 21 20V10.25"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M1 12L11.3292 2.33876C11.7077 1.98577 12.2923 1.98577 12.6708 2.33876L23 12"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
export const BellIcon = ({ size = 24, color = '#ffffff' }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 8A6 6 0 1 0 6 8c0 7-3 9-3 9h18s-3-2-3-9Z"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13.73 21a2 2 0 0 1-3.46 0"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
export const SearchIcon = ({ size = 24, color = '#ffffff' }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="11"
cy="11"
r="7"
stroke={color}
strokeWidth="1.5"
/>
<path
d="M21 21L16.5 16.5"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
)
export const SettingsIcon = ({ size = 24, color = '#ffffff' }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx="12"
cy="12"
r="3"
stroke={color}
strokeWidth="1.5"
/>
</svg>
)
export const UserIcon = ({ size = 24, color = '#ffffff' }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="8"
r="4.5"
stroke={color}
strokeWidth="1.5"
/>
<path
d="M20 21a8 8 0 1 0-16 0"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
)
src/components/Dock/Dock.css
/* ==========================================================
Dock Container & Ambient Backlight Glow
Colours are read from four custom properties, each with the
standalone default baked in as a fallback. The component
writes them from its props, so the CSS works on its own if
you drop it in without the JSX — and a host that wants the
dock to follow its own theme sets the props rather than
editing this file.
========================================================== */
.dock-container {
position: fixed;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
display: flex;
justify-content: center;
align-items: center;
}
.dock-glow {
position: absolute;
bottom: -15px;
left: 50%;
transform: translateX(-50%);
width: 320px;
height: 50px;
background: radial-gradient(
ellipse at center,
color-mix(in srgb, var(--dock-accent, #639aff) 14%, transparent) 0%,
transparent 75%
);
pointer-events: none;
filter: blur(12px);
}
/* ==========================================================
Dock Bar (Fixed 60px Height, Horizontal Dock Stretch)
========================================================== */
.dock {
display: inline-flex;
padding: 10px;
align-items: center;
gap: 15px;
height: 60px;
min-height: 60px;
max-height: 60px; /* Lock navbar dock height to 60px */
background-color: var(--dock-surface, #08101e);
border: 2px solid var(--dock-border, #142544);
border-radius: 20px;
position: relative;
overflow: visible; /* Elevated square buttons pop out top */
box-shadow:
0 8px 30px rgba(0, 0, 0, 0.18),
0 2px 6px rgba(0, 0, 0, 0.08);
box-sizing: border-box;
}
/* ==========================================================
Dock Item Container
========================================================== */
.dock-item-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* ==========================================================
Dock Item Button (Strict 1:1 Aspect Ratio Square Buttons)
========================================================== */
.dock-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
aspect-ratio: 1 / 1; /* Guaranteed 1:1 square button */
background: var(--dock-surface, #08101e);
border: 2px solid var(--dock-item-border, #0c1f43);
border-radius: 14px;
cursor: pointer;
outline: none;
position: relative;
/* The ring is drawn only for keyboard focus, but it must exist: without it
tabbing through the dock gave no visible indicator at all, and the label
naming each icon-only button never appeared. */
transition: border-color 0.15s ease;
will-change: width, height, transform, box-shadow;
box-sizing: border-box;
}
.dock-item:focus-visible {
outline: 2px solid var(--dock-accent, #639aff);
outline-offset: 3px;
}
.dock-item--active {
border-color: var(--dock-accent, #639aff);
}
.dock-item svg {
width: 24px;
height: 24px;
display: block;
flex-shrink: 0;
stroke: var(--dock-icon, #ffffff);
color: var(--dock-icon, #ffffff);
}
/* ==========================================================
Figma Tooltip Label (Design 297:102)
========================================================== */
.dock-tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
white-space: nowrap;
padding: 3px 8px;
background: var(--dock-surface, #08101e);
border: 1px solid var(--dock-border, #142544);
border-radius: 6px;
font-family: 'Product Sans', 'Inter', -apple-system, sans-serif;
font-size: 11px;
line-height: 1.2;
font-weight: 500;
color: var(--dock-icon, #ffffff);
pointer-events: none;
z-index: 30;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
src/components/Dock/Dock.d.ts
import type { ComponentPropsWithoutRef, ComponentType } from 'react'
export interface DockItem {
id: string
label: string
/** Rendered inside the button, given `size` and `color`. */
Icon: ComponentType<{ size?: number; color?: string }>
}
export interface DockProps extends ComponentPropsWithoutRef<'div'> {
/** Defaults to the five-icon row the demos show. */
items?: DockItem[]
/** Controlled selection. Pass `null` for none. */
activeId?: string | null
/** Starting selection when uncontrolled. */
defaultActiveId?: string | null
/** Fires on every activation, including deselection, where `id` is null. */
onSelect?: (id: string | null, item: DockItem | null, index: number) => void
/** Item size at rest, in px. */
baseSize?: number
/** Item size directly under the pointer, in px. */
magnifiedSize?: number
surface?: string
border?: string
itemBorder?: string
accent?: string
icon?: string
}
declare const Dock: (props: DockProps) => JSX.Element
export default Dock