Skip to content
goodtabs UI
BrowseBrowse components

Dock

A floating pill of icon buttons with tooltips, a hover lift and a dot on the active one.

npx shadcn@latest add https://ui.goodtabs.co/r/dock.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/dock

Five apps in the flow, Home active. Hover for the label and the lift.

With a separator

A hairline between the product links and a settings group.

Small

32px icons for a compact bar.

Usage

import { Dock } from "@/components/ui/dock"

<Dock
  position="fixed"
  items={[
    { label: "Home", href: "/", icon: <HomeIcon />, active: true },
    { label: "Mail", href: "/mail", icon: <MailIcon /> },
    { type: "separator" },
    { label: "Settings", onClick: openSettings, icon: <SettingsIcon /> },
  ]}
/>

Props

PropTypeDefaultDescription
items({ label: string; icon: ReactNode; href?: string; onClick?: () => void; active?: boolean } | { type: "separator" })[]Icons in order. A separator object draws a hairline between groups.
position"static" | "fixed""static"static sits in the flow; fixed floats at the bottom centre of the viewport.
size"default" | "sm""default"40px or 32px icon buttons.
labelstring"Dock"Accessible name of the nav landmark.

Source

dock/dock.tsx
"use client";

import * as React from "react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";

export type DockItem = {
  label: string;
  icon: React.ReactNode;
  /** Renders a link. */
  href?: string;
  /** Renders a button. Ignored when href is set. */
  onClick?: () => void;
  /** Marks the current app with a dot under the icon. */
  active?: boolean;
};

export type DockSeparator = { type: "separator" };

type DockProps = Omit<React.ComponentProps<"nav">, "children"> & {
  /** Icons in order. Put { type: "separator" } between groups. */
  items: (DockItem | DockSeparator)[];
  /** static sits in the flow; fixed floats at the bottom centre of the viewport. */
  position?: "static" | "fixed";
  /** 40px icons by default, 32px for sm. */
  size?: "default" | "sm";
  /** Accessible name of the nav. */
  label?: string;
};

/**
 * A row of icon buttons in a floating pill, each with a tooltip.
 * The hovered icon lifts 2px; the active one carries a dot underneath.
 */
export function Dock({ items, position = "static", size = "default", label = "Dock", className, ...props }: DockProps) {
  return (
    <TooltipProvider>
      <nav
        data-slot="dock"
        data-size={size}
        aria-label={label}
        className={cn(
          "inline-flex items-center gap-1 rounded-full border bg-popover shadow-md",
          size === "sm" ? "p-1" : "p-1.5",
          position === "fixed" && "fixed bottom-4 left-1/2 z-40 -translate-x-1/2",
          className,
        )}
        {...props}
      >
        {items.map((item, i) =>
          "type" in item ? (
            <span
              key={`separator-${i}`}
              role="separator"
              aria-orientation="vertical"
              className={cn("mx-1 w-px shrink-0 bg-border", size === "sm" ? "h-5" : "h-6")}
            />
          ) : (
            <DockButton key={`${item.label}-${i}`} item={item} size={size} />
          ),
        )}
      </nav>
    </TooltipProvider>
  );
}

function DockButton({ item, size }: { item: DockItem; size: "default" | "sm" }) {
  const className = cn(
    "relative rounded-full transition-[background-color,color,transform] duration-150 ease-out hover:bg-muted motion-safe:hover:-translate-y-0.5",
    size === "sm" ? "size-8 [&_svg:not([class*='size-'])]:size-4" : "size-10 [&_svg:not([class*='size-'])]:size-5",
  );
  const content = (
    <>
      {item.icon}
      <span className="sr-only">{item.label}</span>
      {item.active && (
        <span
          aria-hidden="true"
          data-slot="dock-dot"
          className="absolute -bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full bg-foreground"
        />
      )}
    </>
  );

  return (
    <Tooltip>
      <TooltipTrigger asChild>
        {item.href ? (
          <Button variant="ghost" size="icon" asChild className={className}>
            <a href={item.href} aria-current={item.active ? "page" : undefined}>
              {content}
            </a>
          </Button>
        ) : (
          <Button
            type="button"
            variant="ghost"
            size="icon"
            onClick={item.onClick}
            aria-current={item.active ? "true" : undefined}
            className={className}
          >
            {content}
          </Button>
        )}
      </TooltipTrigger>
      <TooltipContent side="top" sideOffset={8}>
        {item.label}
      </TooltipContent>
    </Tooltip>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/dock/prompt.md, or the Copy AI prompt button above.

When to use

  • Three to eight top-level destinations that fit as icons
  • A floating bar at the bottom of a portfolio, a tool or a mobile layout
  • When the labels can live in tooltips because the icons are familiar

When not to use

  • When the icons are not obvious: a labelled sidebar or navbar reads faster
  • For more than eight items; a dock that scrolls stops being a dock
  • Inside a page section as a toolbar, use toolbar instead

Agent notes

  • Items with href render anchors, the rest render buttons with onClick; pass handlers from a client component
  • position="fixed" pins it at bottom-4 of the viewport at z-40, so leave room under the page content
  • The lift is a 2px transform over 150ms and is off under prefers-reduced-motion; the dot marks active without motion
  • Tooltips come from shadcn tooltip and open on hover and focus; each icon also has an sr-only label