Skip to content

Dock ​

A magnifying dock where icons grow as the pointer approaches.

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/dock.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/dock/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { motion, useMotionValue } from 'motion-v'
import { provide } from 'vue'
import { cn } from '@/lib/utils'
import { DOCK_KEY } from './context'

const props = withDefaults(defineProps<{
  /** Resting icon size in px. */
  baseSize?: number
  /** Icon size directly under the pointer in px. */
  magnification?: number
  /** Pointer distance (px) over which neighbours grow. */
  distance?: number
  class?: HTMLAttributes['class']
}>(), {
  baseSize: 40,
  magnification: 64,
  distance: 140,
})

const mouseX = useMotionValue(Number.POSITIVE_INFINITY)

provide(DOCK_KEY, {
  mouseX,
  baseSize: props.baseSize,
  magnification: props.magnification,
  distance: props.distance,
})
</script>

<template>
  <motion.div
    role="toolbar"
    aria-orientation="horizontal"
    :class="cn(
      'mx-auto flex w-max items-end gap-2 rounded-2xl border bg-background/70 px-2.5 pb-2.5 shadow-lg backdrop-blur-md',
      props.class,
    )"
    :style="{ height: `${props.baseSize + 20}px` }"
    @pointermove="mouseX.set($event.pageX)"
    @pointerleave="mouseX.set(Number.POSITIVE_INFINITY)"
  >
    <slot />
  </motion.div>
</template>
vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AnimatePresence, motion, useDomRef, useSpring, useTransform } from 'motion-v'
import { inject, ref } from 'vue'
import { cn } from '@/lib/utils'
import { DOCK_KEY } from './context'

const props = defineProps<{
  label: string
  href?: string
  active?: boolean
  class?: HTMLAttributes['class']
}>()

const emit = defineEmits<{ click: [MouseEvent] }>()

const ctx = inject(DOCK_KEY)
if (!ctx)
  throw new Error('<DockItem> must be used inside <Dock>')

const el = useDomRef()
const hovered = ref(false)

const offset = useTransform(ctx.mouseX, (x) => {
  const rect = (el.value as HTMLElement | null)?.getBoundingClientRect()
  if (!rect || !Number.isFinite(x))
    return Number.POSITIVE_INFINITY
  return x - (rect.left + window.scrollX) - rect.width / 2
})

const target = useTransform(
  offset,
  [-ctx.distance, 0, ctx.distance],
  [ctx.baseSize, ctx.magnification, ctx.baseSize],
  { clamp: true },
)
const size = useSpring(target, { mass: 0.1, stiffness: 160, damping: 13 })
</script>

<template>
  <motion.div
    ref="el"
    :as="props.href ? 'a' : 'button'"
    :href="props.href"
    :type="props.href ? undefined : 'button'"
    :aria-label="props.label"
    :class="cn(
      'relative flex aspect-square items-center justify-center rounded-xl bg-muted text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-1/2',
      props.class,
    )"
    :style="{ width: size, height: size }"
    @pointerenter="hovered = true"
    @pointerleave="hovered = false"
    @focus="hovered = true"
    @blur="hovered = false"
    @click="emit('click', $event)"
  >
    <slot />
    <AnimatePresence>
      <motion.span
        v-if="hovered"
        role="presentation"
        class="pointer-events-none absolute -top-9 left-1/2 whitespace-nowrap rounded-md border bg-popover px-2 py-0.5 text-xs text-popover-foreground shadow-sm"
        :initial="{ opacity: 0, y: 6, x: '-50%' }"
        :animate="{ opacity: 1, y: 0, x: '-50%' }"
        :exit="{ opacity: 0, y: 2, x: '-50%' }"
        :transition="{ duration: 0.15 }"
      >
        {{ props.label }}
      </motion.span>
    </AnimatePresence>
    <span
      v-if="props.active"
      aria-hidden="true"
      class="absolute -bottom-2 left-1/2 size-1 -translate-x-1/2 rounded-full bg-foreground/70"
    />
  </motion.div>
</template>
ts
import type { MotionValue } from 'motion-v'
import type { InjectionKey } from 'vue'

export interface DockContext {
  mouseX: MotionValue<number>
  baseSize: number
  magnification: number
  distance: number
}

export const DOCK_KEY: InjectionKey<DockContext> = Symbol('VuelixDock')
ts
export { default as Dock } from './Dock.vue'
export { default as DockItem } from './DockItem.vue'

Usage ​

ts
import { Dock, DockItem } from '@/components/ui/dock'
vue
<Dock :magnification="64">
  <DockItem label="Home"><Home /></DockItem>
  <DockItem label="Mail" href="/mail"><Mail /></DockItem>
</Dock>

API ​

PropTypeDefaultDescription
Dock · baseSizenumber40Resting icon size (px).
Dock · magnificationnumber64Size under the pointer (px).
Dock · distancenumber140Range of influence (px).
DockItem · labelstring—Accessible label + hover tag.
DockItem · hrefstring—Render as a link.
DockItem · activebooleanfalseShow the running indicator.

Designed by Kuldeep Pisda · Released under the MIT License