shadimage
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 · medium
animationframer-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

More from morphiq

Browse all components in this library →