Dock Rails Components

Create a dock menu with icons and labels. Perfect for creating a dock menu.

Installation

1. Stimulus Controller Setup

Start by adding the following controller to your project:

import { Controller } from "@hotwired/stimulus";
import { animate, stagger, spring, transform } from "motion";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";

export default class extends Controller {
  static targets = ["icon", "mobileMenu", "mobileButton", "tooltip", "tooltipContent"];

  connect() {
    this.isToggling = false;
    this.initDesktopHover();
    this.initTooltips();

    // Update active menu styling on Turbo page loads.
    this.updateActiveMenu();
    this.updateActiveMenuBound = this.updateActiveMenu.bind(this);
    document.addEventListener("turbo:load", this.updateActiveMenuBound);

    // Restore any tooltip if still hovering during a Turbo transition.
    this.restoreTooltipBound = this.restoreTooltip.bind(this);
    document.addEventListener("turbo:load", this.restoreTooltipBound);
  }

  // Desktop hover animations
  initDesktopHover() {
    this.mouseX = Infinity;
    this.animations = new Map();

    // Debounce the animation frame request
    let frameRequest;
    const updateAnimations = () => {
      frameRequest = requestAnimationFrame(() => this.updateIconAnimations());
    };

    this.element.addEventListener("mousemove", (e) => {
      this.mouseX = e.pageX;
      if (frameRequest) {
        cancelAnimationFrame(frameRequest);
      }
      updateAnimations();
    });

    this.element.addEventListener("mouseleave", () => {
      this.mouseX = Infinity;
      if (frameRequest) {
        cancelAnimationFrame(frameRequest);
      }
      updateAnimations();
    });
  }

  updateIconAnimations() {
    // Add transform utility for smooth size mapping
    const sizeTransformer = transform(
      [-150, 0, 150], // Input range (distance from center)
      [40, 80, 40] // Output range (icon size)
    );

    this.iconTargets.forEach((icon) => {
      const rect = icon.getBoundingClientRect();
      const distance = this.mouseX - (rect.x + rect.width / 2);
      const targetSize = sizeTransformer(distance);
      const iconElement = icon.querySelector("div:first-child");
      if (!iconElement) return;

      // Use spring animation with proper physics parameters
      animate(
        iconElement,
        { width: `${targetSize}px`, height: `${targetSize}px` },
        {
          duration: 2,
          easing: "easeOut",
          type: spring,
          stiffness: 1150,
          damping: 80,
          mass: 0.7,
          restDelta: 0.1,
        }
      );
    });
  }

  // Mobile toggle functionality
  async toggleMobile() {
    if (this.isToggling) return;
    this.isToggling = true;
    const is_open = this.mobileMenuTarget.classList.contains("hidden");
    if (is_open) {
      // Opening: remove hidden immediately and enable interactions
      this.mobileMenuTarget.classList.remove("hidden");
      this.mobileMenuTarget.style.pointerEvents = "auto"; // re-enable pointer events
    } else {
      // Closing: disable pointer events immediately to prevent hover interactions
      this.mobileMenuTarget.style.pointerEvents = "none";
    }
    await new Promise((resolve) => requestAnimationFrame(resolve));
    const menuPromise = this.animateMobileMenu(is_open);
    const buttonPromise = this.rotateMobileButton(is_open);
    await Promise.all([menuPromise, buttonPromise]);
    if (!is_open) {
      // Closing: add hidden once animation completes
      this.mobileMenuTarget.classList.add("hidden");
    }
    this.isToggling = false;
  }

  animateMobileMenu(is_open) {
    const menuItems = Array.from(this.mobileMenuTarget.children);
    const properties = {
      opacity: [is_open ? 0 : 1, is_open ? 1 : 0],
      y: [is_open ? 20 : 0, is_open ? 0 : 10],
      scale: [is_open ? 0.9 : 1, is_open ? 1 : 0.95],
    };

    const options = is_open
      ? {
          delay: stagger(0.01, { start: 0.1, from: "first" }),
          duration: 0.3,
          type: spring,
          stiffness: 500,
          damping: 25,
          mass: 1.5,
        }
      : {
          delay: stagger(0.01, { from: "last" }),
          duration: 0.1,
          type: spring,
          stiffness: 400,
          damping: 45,
          mass: 1.5,
        };

    const animations = animate(menuItems, properties, options);
    // If multiple elements are animated, wait for all animations to finish
    if (Array.isArray(animations)) {
      return Promise.all(animations.map((anim) => anim.finished));
    }
    return animations.finished;
  }

  rotateMobileButton(is_open) {
    const buttonIcon = this.mobileButtonTarget.querySelector("svg");
    if (!buttonIcon) return Promise.resolve();
    return animate(
      buttonIcon,
      { rotate: is_open ? "180deg" : "0deg" },
      {
        duration: 2,
        easing: "easeOut",
        type: spring,
        stiffness: 1150,
        damping: 80,
        mass: 5,
        restDelta: 0.1,
      }
    ).finished;
  }

  // Initialize tooltips for each icon
  initTooltips() {
    this.tooltip = document.createElement("div");
    this.tooltip.className =
      "absolute z-50 px-1.5 py-1 text-xs font-medium text-neutral-900 bg-white border border-neutral-200/70 rounded-md shadow-sm opacity-0 dark:bg-neutral-800 dark:border-neutral-600/50 dark:text-neutral-100";
    this.tooltip.style.position = "fixed";
    document.body.appendChild(this.tooltip);

    this.cleanupEventListeners = [];
    this.tooltipLoopId = null;

    const setupTooltipHandlers = (icon) => {
      const mouseEnterHandler = () => {
        this.activeIcon = icon;
        const tooltipText = icon.dataset.tooltip;
        const hotkey = icon.dataset.tooltipHotkey;
        if (hotkey && hotkey.trim() !== "") {
          this.tooltip.innerHTML = `${tooltipText} <span class="tooltip-hotkey uppercase text-[10px] font-semibold ml-0.5 px-1 py-px bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded">${hotkey}</span>`;
        } else {
          this.tooltip.textContent = tooltipText;
        }
        this.positionTooltip(icon);
        animate(this.tooltip, { opacity: 1 }, { duration: 0.3 });
        const updateTooltip = () => {
          if (this.activeIcon) {
            this.positionTooltip(this.activeIcon);
            this.tooltipLoopId = requestAnimationFrame(updateTooltip);
          }
        };
        this.tooltipLoopId = requestAnimationFrame(updateTooltip);
      };

      const mouseLeaveHandler = () => {
        this.activeIcon = null;
        animate(this.tooltip, { opacity: 0 }, { duration: 0.1 });
        if (this.tooltipLoopId) {
          cancelAnimationFrame(this.tooltipLoopId);
          this.tooltipLoopId = null;
        }
      };

      icon.addEventListener("mouseenter", mouseEnterHandler);
      icon.addEventListener("mouseleave", mouseLeaveHandler);
      this.cleanupEventListeners.push(() => {
        icon.removeEventListener("mouseenter", mouseEnterHandler);
        icon.removeEventListener("mouseleave", mouseLeaveHandler);
      });
    };

    this.iconTargets.forEach(setupTooltipHandlers);
  }

  positionTooltip(icon) {
    // Use data-tooltip-placement from the icon, defaulting to "top"
    const placement = icon.dataset.tooltipPlacement || "top";
    computePosition(icon, this.tooltip, {
      placement: placement,
      strategy: "fixed",
      middleware: [
        offset(8),
        flip(),
        shift({
          padding: 4,
          crossAxis: true,
        }),
      ],
    }).then(({ x, y }) => {
      Object.assign(this.tooltip.style, {
        left: `${x}px`,
        top: `${y}px`,
      });
    });
  }

  disconnect() {
    if (this.tooltip) this.tooltip.remove();
    if (this.tooltipLoopId) {
      cancelAnimationFrame(this.tooltipLoopId);
      this.tooltipLoopId = null;
    }
    this.cleanupEventListeners.forEach((cleanup) => cleanup());
    document.removeEventListener("turbo:load", this.updateActiveMenuBound);
    document.removeEventListener("turbo:load", this.restoreTooltipBound);
  }

  /**
   * Iterates over each menu item and updates its styling based on the current URL.
   * Both desktop and mobile menu items are updated.
   */
  updateActiveMenu() {
    const desktopActiveClasses =
      "text-neutral-50 dark:text-neutral-800 bg-neutral-800 dark:bg-neutral-100 active:bg-neutral-700 dark:active:bg-neutral-200";
    const desktopInactiveClasses =
      "text-neutral-500 dark:text-neutral-300 bg-neutral-200 dark:bg-neutral-800 active:bg-neutral-300 dark:active:bg-neutral-700";
    const mobileActiveClasses =
      "text-neutral-50 dark:text-neutral-800 hover:text-neutral-50 dark:hover:text-neutral-800 bg-neutral-800 dark:bg-neutral-100 active:bg-neutral-700 dark:active:bg-neutral-200";
    const mobileInactiveClasses =
      "text-neutral-500 dark:text-neutral-300 hover:text-neutral-500 dark:hover:text-neutral-300 bg-neutral-50 dark:bg-neutral-800 active:bg-neutral-100 dark:active:bg-neutral-700";

    // Update desktop icons
    this.iconTargets.forEach((anchor) => {
      const iconBox = anchor.querySelector("div");
      if (!iconBox) return;
      const url = anchor.getAttribute("href");
      try {
        // Compare the anchor URL (resolved relative to the current origin) with the current pathname and search params.
        const anchorUrl = new URL(url, window.location.origin);
        // If the menu item has no search params, match only by pathname.
        // If it has search params, match both pathname and search params exactly.
        const isActive =
          anchorUrl.search === ""
            ? anchorUrl.pathname === window.location.pathname
            : anchorUrl.pathname === window.location.pathname && anchorUrl.search === window.location.search;
        if (isActive) {
          iconBox.classList.remove(...desktopInactiveClasses.split(" "));
          iconBox.classList.add(...desktopActiveClasses.split(" "));
        } else {
          iconBox.classList.remove(...desktopActiveClasses.split(" "));
          iconBox.classList.add(...desktopInactiveClasses.split(" "));
        }
      } catch (e) {
        // Skip if URL parsing fails.
      }
    });

    // Update mobile menu items (if present)
    if (this.hasMobileMenuTarget) {
      const mobileAnchors = this.mobileMenuTarget.querySelectorAll("a");
      mobileAnchors.forEach((anchor) => {
        const url = anchor.getAttribute("href");
        try {
          const anchorUrl = new URL(url, window.location.origin);
          // If the menu item has no search params, match only by pathname.
          // If it has search params, match both pathname and search params exactly.
          const isActive =
            anchorUrl.search === ""
              ? anchorUrl.pathname === window.location.pathname
              : anchorUrl.pathname === window.location.pathname && anchorUrl.search === window.location.search;
          if (isActive) {
            anchor.classList.remove(...mobileInactiveClasses.split(" "));
            anchor.classList.add(...mobileActiveClasses.split(" "));
          } else {
            anchor.classList.remove(...mobileActiveClasses.split(" "));
            anchor.classList.add(...mobileInactiveClasses.split(" "));
          }
        } catch (e) {}
      });
    }
  }

  /**
   * If an icon is still active (hovered) when Turbo loads a new page,
   * re-position the tooltip and force its opacity so it remains visible.
   */
  restoreTooltip() {
    if (this.activeIcon) {
      const tooltipText = this.activeIcon.dataset.tooltip;
      const hotkey = this.activeIcon.dataset.tooltipHotkey;
      if (hotkey && hotkey.trim() !== "") {
        this.tooltip.innerHTML = `${tooltipText} <span class="tooltip-hotkey uppercase text-[10px] font-semibold ml-0.5 px-1 py-px bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded">${hotkey}</span>`;
      } else {
        this.tooltip.textContent = tooltipText;
      }
      this.positionTooltip(this.activeIcon);
      this.tooltip.style.opacity = 1;
      if (!this.tooltipLoopId) {
        const updateTooltipLoop = () => {
          if (this.activeIcon) {
            this.positionTooltip(this.activeIcon);
            this.tooltipLoopId = requestAnimationFrame(updateTooltipLoop);
          }
        };
        this.tooltipLoopId = requestAnimationFrame(updateTooltipLoop);
      }
    }
  }
}

2. Motion.dev & Floating UI Installation

pin "motion", to: "https://cdn.jsdelivr.net/npm/motion@latest/+esm"
Terminal
npm install motion
Terminal
yarn add motion
pin "@floating-ui/dom", to: "https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.7.3/+esm"
Terminal
npm install @floating-ui/dom
Terminal
yarn add @floating-ui/dom

Examples

Basic Dock

2 examples dock menus with icons, hotkeys and tooltips.

<div class="w-full max-w-md">
  <% menu_items = [
    { url: "#", tooltip: "Home", hotkey: "h", svg: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M14.855,6.2l-5.25-3.99c-.358-.272-.853-.272-1.21,0L3.145,6.2c-.249,.189-.395,.484-.395,.797v7.254c0,1.105,.895,2,2,2h2.5v-4c0-.552,.448-1,1-1h1.5c.552,0,1,.448,1,1v4h2.5c1.105,0,2-.895,2-2V6.996c0-.313-.146-.607-.395-.797Z"></path></g></svg>' },
    { url: "#", tooltip: "Articles", hotkey: "a", svg: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M14.25,12.25v2c0,1.105-.895,2-2,2H3.75c-1.105,0-2-.895-2-2V3.75c0-1.105,.895-2,2-2H12.25c1.105,0,2,.895,2,2v1.5"></path><line x1="4.75" y1="5.75" x2="9.25" y2="5.75"></line><line x1="4.75" y1="8.75" x2="7" y2="8.75"></line><line x1="4.75" y1="11.75" x2="6.25" y2="11.75"></line><path d="M12.375,10.625c.444-.444,2.948-2.948,4.216-4.216,.483-.483,.478-1.261-.005-1.745h0c-.483-.483-1.261-.489-1.745-.005-1.268,1.268-3.772,3.772-4.216,4.216-.625,.625-1.125,2.875-1.125,2.875,0,0,2.25-.5,2.875-1.125Z"></path></g></svg>' },
    { url: "#", tooltip: "Projects", hotkey: "p", svg: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><circle cx="8" cy="5.5" r="3.75"></circle><path d="M1.953,15c1.298-1.958,3.522-3.25,6.047-3.25"></path><path d="M14.925,16.25h-6.175l1.868-4.203c.08-.181,.259-.297,.457-.297h5.406c.362,0,.604,.372,.457,.703l-1.556,3.5c-.08,.181-.259,.297-.457,.297Z"></path><line x1="8.75" y1="16.25" x2="5.75" y2="16.25"></line></g></svg>' },
    { url: "#", tooltip: "Courses", hotkey: "c", svg: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M13.25,9.75c.361,0,.705-.077,1.015-.214"></path><path d="M12.961,5.962c.336-.393,.539-.904,.539-1.462"></path><path d="M12.221,12.965c.301,.181,.653,.285,1.029,.285,.076,0,.15-.004,.224-.012"></path><path d="M9,4.5c0-1.243,1.007-2.25,2.25-2.25,1.243,0,2.25,1.007,2.25,2.25,0,.093-.016,.182-.027,.272,1.275,.114,2.277,1.173,2.277,2.478,0,1.02-.613,1.895-1.489,2.283,.589,.348,.989,.983,.989,1.717,0,1.028-.779,1.865-1.777,1.978,.011,.09,.027,.179,.027,.272,0,1.243-1.007,2.25-2.25,2.25-1.243,0-2.25-1.007-2.25-2.25"></path><path d="M4.75,9.75c-.361,0-.705-.077-1.015-.214"></path><path d="M5.039,5.962c-.336-.393-.539-.904-.539-1.462"></path><path d="M5.779,12.965c-.301,.181-.653,.285-1.029,.285-.076,0-.15-.004-.224-.012"></path><path d="M9,4.5c0-1.243-1.007-2.25-2.25-2.25s-2.25,1.007-2.25,2.25c0,.093,.016,.182,.027,.272-1.275,.114-2.277,1.173-2.277,2.478,0,1.02,.613,1.895,1.489,2.283-.589,.348-.989,.983-.989,1.717,0,1.028,.779,1.865,1.777,1.978-.011,.09-.027,.179-.027,.272,0,1.243,1.007,2.25,2.25,2.25s2.25-1.007,2.25-2.25"></path><line x1="9" y1="13.5" x2="9" y2="4.5"></line></g></svg>' },
    { url: "#", tooltip: "Books", hotkey: "b", svg: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><title>books</title><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M16,6.75c-.171-.387-.419-1.083-.422-1.984-.003-.918,.25-1.626,.422-2.016"></path><path d="M16.75,2.75H6.25c-1.105,0-2,.895-2,2h0c0,1.105,.895,2,2,2h10.5"></path><path d="M16,14.75c-.171-.387-.419-1.083-.422-1.984-.003-.918,.25-1.626,.422-2.016"></path><line x1="13.75" y1="14.75" x2="16.75" y2="14.75"></line><path d="M16.75,10.75H6.25c-1.105,0-2,.895-2,2h0c0,1.105,.895,2,2,2"></path><path d="M2,10.75c.171-.387,.419-1.083,.422-1.984,.003-.918-.25-1.626-.422-2.016"></path><path d="M1.25,6.75H11.75c1.105,0,2,.895,2,2h0c0,1.105-.895,2-2,2H1.25"></path><path d="M12,13h-4v3.5c0,.202,.122,.385,.309,.462,.187,.079,.401,.035,.545-.108l1.146-1.146,1.146,1.146c.096,.096,.224,.146,.354,.146,.064,0,.13-.012,.191-.038,.187-.077,.309-.26,.309-.462v-3.5Z" fill="currentColor" data-stroke="none" stroke="none"></path></g></svg>' },
    { url: "#", tooltip: "Timeline", hotkey: "t", svg: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><rect x="13.25" y="2.75" width="3.5" height="3.5" rx="1" ry="1"></rect><rect x="13.25" y="11.75" width="3.5" height="3.5" rx="1" ry="1"></rect><rect x="1.25" y="6.75" width="3.5" height="3.5" rx="1" ry="1"></rect><line x1="9" y1="1.75" x2="9" y2="16.25"></line><line x1="10.75" y1="4.75" x2="9" y2="4.75"></line><line x1="9" y1="8.25" x2="7.25" y2="8.25"></line><line x1="10.75" y1="13.25" x2="9" y2="13.25"></line></g></svg>' },
    { url: "#", tooltip: "Newsletter", hotkey: "n", svg: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M1.75,5.75l6.767,3.733c.301,.166,.665,.166,.966,0l6.767-3.733"></path><path d="M16.25,9.79V5.25c0-1.104-.895-2-2-2H3.75c-1.105,0-2,.896-2,2v7.5c0,1.104,.895,2,2,2h5.07"></path><polygon points="14.25 11.25 15.25 13.25 17.25 14.25 15.25 15.25 14.25 17.25 13.25 15.25 11.25 14.25 13.25 13.25 14.25 11.25"></polygon></g></svg>' }
  ] %>

  <div class="border border-neutral-200 rounded-xl p-4 bg-white dark:bg-neutral-800/50 dark:border-neutral-600 relative">
    <p class="hidden md:block text-sm text-neutral-600 dark:text-neutral-300 text-center">Desktop version - with hotkeys - tooltp above icons</p>
    <p class="text-sm text-neutral-600 dark:text-neutral-300 text-center">Mobile version</p>
    <div class="rounded-xl h-[16rem] flex items-end justify-center">
      <div class="flex w-fit mx-auto items-center justify-center" data-controller="dock">
        <div class="mx-auto hidden h-16 items-end gap-4 rounded-2xl bg-neutral-50 px-4 pb-3 md:flex dark:bg-neutral-950 border border-neutral-200/50 dark:border-neutral-600/50">
          <% menu_items.each do |item| %>
            <% active = current_page?(item[:url]) %>
            <a href="<%= item[:url] %>" class="rounded-full" data-dock-target="icon" data-tooltip-hotkey="<%= item[:hotkey] %>" data-tooltip="<%= item[:tooltip] %>" data-tooltip-placement="top" <%= "data-controller=hotkey data-action=keydown.#{item[:hotkey]}@document->hotkey#click" if item[:hotkey].present? %>>
              <div class="size-10 relative flex aspect-square items-center justify-center rounded-full border border-neutral-200/50 dark:border-neutral-600/50  <%= active ? 'text-neutral-50 dark:text-neutral-800 bg-neutral-800 dark:bg-neutral-100 active:bg-neutral-700 dark:active:bg-neutral-200' : 'text-neutral-500 dark:text-neutral-300 bg-neutral-200 dark:bg-neutral-800 active:bg-neutral-300 dark:active:bg-neutral-700' %>">
                <div class="flex items-center justify-center *:size-full" style="width: 50%; height: 50%">
                  <%= raw(item[:svg]) %>
                </div>
              </div>
            </a>
          <% end %>
        </div>
        <div class="absolute right-5 bottom-17 flex items-end flex-col gap-2 hidden md:hidden" data-dock-target="mobileMenu">
          <% menu_items.each do |item| %>
            <% active = current_page?(item[:url]) %>
            <a href="<%= item[:url] %>" data-tooltip="<%= item[:tooltip] %>" class="opacity-0 px-3 no-underline gap-2 w-fit size-10 flex items-center justify-center rounded-full border border-neutral-200/50 dark:border-neutral-600/50 <%= active ? 'text-neutral-50 dark:text-neutral-800 hover:text-neutral-50 dark:hover:text-neutral-800 bg-neutral-800 dark:bg-neutral-100 active:bg-neutral-700 dark:active:bg-neutral-200' : 'text-neutral-500 dark:text-neutral-300 hover:text-neutral-500 dark:hover:text-neutral-300 bg-neutral-50 dark:bg-neutral-800 active:bg-neutral-100 dark:active:bg-neutral-700' %>">
              <span class="text-sm"><%= item[:tooltip] %></span>
              <span class="*:size-5"><%= raw(item[:svg]) %></span>
            </a>
          <% end %>
        </div>
        <div class="absolute right-5 bottom-5 block md:hidden"
            data-action="click->dock#toggleMobile">
          <button data-dock-target="mobileButton" class="flex h-10 w-10 items-center justify-center border border-neutral-200/50 rounded-full bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-600/50">
            <svg class="h-5 w-5 rotate-0 text-neutral-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><polyline points="2.75 11.5 9 5.25 15.25 11.5"></polyline></g></svg>
          </button>
        </div>
      </div>
    </div>
  </div>

  <div class="mt-10 border border-neutral-200 rounded-xl p-4 bg-white dark:bg-neutral-800/50 dark:border-neutral-600 relative">
    <div class="rounded-xl h-[16rem] flex items-start justify-center">
      <div class="flex w-fit mx-auto items-center justify-center" data-controller="dock">
        <div class="mx-auto hidden h-16 items-start gap-4 rounded-2xl bg-neutral-50 px-4 pt-3 md:flex dark:bg-neutral-950 border border-neutral-200/50 dark:border-neutral-600/50">
          <% menu_items.each do |item| %>
            <% active = current_page?(item[:url]) %>
            <a href="<%= item[:url] %>" class="rounded-full" data-dock-target="icon" data-tooltip-hotkey="<%= item[:hotkey] %>" data-tooltip="<%= item[:tooltip] %>" data-tooltip-placement="bottom" <%= "data-controller=hotkey data-action=keydown.#{item[:hotkey]}@document->hotkey#click" if item[:hotkey].present? %>>
              <div class="size-10 relative flex aspect-square items-center justify-center rounded-full border border-neutral-200/50 dark:border-neutral-600/50  <%= active ? 'text-neutral-50 dark:text-neutral-800 bg-neutral-800 dark:bg-neutral-100 active:bg-neutral-700 dark:active:bg-neutral-200' : 'text-neutral-500 dark:text-neutral-300 bg-neutral-200 dark:bg-neutral-800 active:bg-neutral-300 dark:active:bg-neutral-700' %>">
                <div class="flex items-center justify-center *:size-full" style="width: 50%; height: 50%">
                  <%= raw(item[:svg]) %>
                </div>
              </div>
            </a>
          <% end %>
        </div>
        <div class="absolute right-5 bottom-17 flex items-end flex-col gap-2 hidden md:hidden" data-dock-target="mobileMenu">
          <% menu_items.each do |item| %>
            <% active = current_page?(item[:url]) %>
            <a href="<%= item[:url] %>" data-tooltip="<%= item[:tooltip] %>" class="opacity-0 px-3 no-underline gap-2 w-fit size-10 flex items-center justify-center rounded-full border border-neutral-200/50 dark:border-neutral-600/50 <%= active ? 'text-neutral-50 dark:text-neutral-800 hover:text-neutral-50 dark:hover:text-neutral-800 bg-neutral-800 dark:bg-neutral-100 active:bg-neutral-700 dark:active:bg-neutral-200' : 'text-neutral-500 dark:text-neutral-300 hover:text-neutral-500 dark:hover:text-neutral-300 bg-neutral-50 dark:bg-neutral-800 active:bg-neutral-100 dark:active:bg-neutral-700' %>">
              <span class="text-sm"><%= item[:tooltip] %></span>
              <span class="*:size-5"><%= raw(item[:svg]) %></span>
            </a>
          <% end %>
        </div>
        <div class="absolute right-5 bottom-5 block md:hidden"
            data-action="click->dock#toggleMobile">
          <button data-dock-target="mobileButton" class="flex h-10 w-10 items-center justify-center border border-neutral-200/50 rounded-full bg-neutral-50 dark:bg-neutral-800 dark:border-neutral-600/50">
            <svg class="h-5 w-5 rotate-0 text-neutral-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><polyline points="2.75 11.5 9 5.25 15.25 11.5"></polyline></g></svg>
          </button>
        </div>
      </div>
    </div>
    <p class="hidden md:block text-sm text-neutral-600 dark:text-neutral-300 text-center">Desktop version - with hotkeys - tooltp below icons</p>
    <p class="text-sm text-neutral-600 dark:text-neutral-300 text-center">Mobile version</p>
  </div>
</div>

Configuration

The dock component is powered by a Stimulus controller that provides smooth hover animations, tooltips with hotkeys, mobile menu functionality, and automatic active state management based on the current URL.

Controller Setup

Basic dock structure with required data attributes:

<div class="flex w-fit mx-auto items-center justify-center" data-controller="dock">
  <div class="mx-auto hidden h-16 items-end gap-4 rounded-2xl bg-neutral-50 px-4 pb-3 md:flex dark:bg-neutral-950 border border-neutral-200/50 dark:border-neutral-600/50">
    <a href="/dashboard" class="rounded-full" data-dock-target="icon" data-tooltip="Dashboard" data-tooltip-hotkey="d" data-tooltip-placement="top">
      <div class="size-10 relative flex aspect-square items-center justify-center rounded-full border border-neutral-200/50 dark:border-neutral-600/50 text-neutral-500 dark:text-neutral-300 bg-neutral-200 dark:bg-neutral-800">
        <div class="flex items-center justify-center *:size-full" style="width: 50%; height: 50%">
          <!-- Icon SVG -->
        </div>
      </div>
    </a>
  </div>
</div>

Targets

Target Description Required
icon
The dock icon elements that animate on hover and display tooltips Required
mobileMenu
The container for mobile menu items that shows/hides on toggle Optional
mobileButton
The button that toggles the mobile menu (icon rotates on click) Optional

Actions

Action Description Usage
toggleMobile
Toggles the mobile menu open/closed with smooth animations click->dock#toggleMobile

Data Attributes

Attribute Description Required
data-tooltip
The text to display in the tooltip when hovering over an icon Required
data-tooltip-hotkey
Keyboard shortcut displayed in the tooltip (e.g., 'h', 'd', 'cmd+k') Optional
data-tooltip-placement
Position of the tooltip relative to the icon (top, bottom, left, right) Optional

Animation Features

  • Spring Physics: Dock icons use Motion's spring animations for natural, responsive hover effects
  • Smart Scaling: Icons grow based on mouse proximity with a smooth distance-based transformation
  • Staggered Mobile Menu: Menu items animate in sequence with configurable stagger delays
  • Smooth Transitions: Button rotations and menu visibility changes use optimized spring physics

Tooltip Features

  • Smart Positioning: Tooltips automatically position using Floating UI with flip and shift middleware
  • Hotkey Display: Optional keyboard shortcuts shown in styled badges within tooltips
  • Continuous Updates: Tooltip position updates in real-time during icon animations
  • Turbo Compatible: Tooltips persist and re-position correctly during Turbo navigation

Active State Management

  • URL-Based: Automatically highlights active menu items based on current pathname and search parameters
  • Desktop & Mobile: Active states update simultaneously for both desktop dock icons and mobile menu items
  • Turbo Support: Active states refresh automatically on Turbo page loads
  • Smart Matching: Supports both simple pathname matching and exact pathname + query parameter matching

Accessibility Features

  • Keyboard Shortcuts: Integrate with the hotkey controller for accessible keyboard navigation
  • Focus Management: Proper focus states for all interactive elements
  • Screen Reader Friendly: Semantic HTML and proper link structure for accessibility
  • Mobile Touch Support: Touch-optimized interactions with proper pointer event handling

Table of contents

Get notified when new components come out