# Tooltip Grid

> Tooltip with a direction aware pixel grid background animation

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/tooltip-grid).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/tooltip-grid
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/tooltip-grid-light-49fa6347a8898d3c.png
- Registry item: https://uicapsule.com/r/tooltip-grid.json
- Categories: navigation, minimal
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/tooltip-grid.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@floating-ui/react`, `motion`

## Files

### `/preview.tsx`

```tsx
"use client";

import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";

const Preview = () => (
  <div className="flex h-screen items-center justify-center bg-slate-800 text-slate-50">
    <Tooltip>
      <TooltipTrigger className="p-4">Hover</TooltipTrigger>
      <TooltipContent type="block">
        <div style={{ aspectRatio: "16/9", width: 200 }} />
      </TooltipContent>
    </Tooltip>
  </div>
);

export default Preview;
```

### `/styles.d.ts`

```ts
declare module "*.css" {}
```

### `/tooltip.css`

```css
.tooltip {
  --colors-panel: #020617;
  --colors-shadowLight: hsl(206 22% 7% / 35%);
  --colors-shadowDark: hsl(206 22% 7% / 20%);

  background-color: var(--colors-panel);
  box-shadow:
    var(--colors-shadowLight) 0px 10px 38px -10px,
    var(--colors-shadowDark) 0px 10px 20px -15px;
  border-radius: 4px;
}

.tooltip.block {
  background-color: transparent;
  box-shadow: none;
}

.tooltip.block .content {
  position: relative;
}

.line {
  --mask-background: #ffffff;
  --fade-stop: 90%;
  --border-color: #334155;

  position: fixed;
  pointer-events: none;
  top: 0;
  mask-composite: exclude;
}

.lineV {
  width: 1px;
  height: 100dvh;

  background: linear-gradient(
    to bottom,
    var(--border-color),
    var(--border-color) 50%,
    transparent 0,
    transparent
  );
  background-size: 1px 5px;

  -webkit-mask:
    linear-gradient(to top, var(--mask-background) var(--fade-stop), transparent),
    linear-gradient(to bottom, var(--mask-background) var(--fade-stop), transparent),
    linear-gradient(black, black);
}

.lineH {
  width: 100dvw;
  height: 1px;

  background: linear-gradient(
    to right,
    var(--border-color),
    var(--border-color) 50%,
    transparent 0,
    transparent
  );
  background-size: 5px 1px;

  -webkit-mask:
    linear-gradient(to left, var(--mask-background) var(--fade-stop), transparent),
    linear-gradient(to right, var(--mask-background) var(--fade-stop), transparent),
    linear-gradient(black, black);
}

.blocksContainer {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: grid;
  grid-template-columns: repeat(var(--cols), 1fr);
  grid-template-rows: repeat(var(--rows), 1fr);
}

.block {
  background-color: var(--colors-panel);
}
```

### `/tooltip.tsx`

```tsx
"use client";

import { cloneElement, createContext, use, useCallback, useMemo, useState } from "react";
import type { CSSProperties, HTMLProps, ReactElement, ReactNode } from "react";

import {
  autoUpdate,
  flip,
  FloatingPortal,
  offset,
  shift,
  useDismiss,
  useFloating,
  useFocus,
  useHover,
  useInteractions,
  useMergeRefs,
  useRole,
} from "@floating-ui/react";
import { AnimatePresence, motion } from "motion/react";

import type { Placement } from "@floating-ui/react";

import "./tooltip.css";

type HoverDirection =
  | "top-left"
  | "top-center"
  | "top-right"
  | "bottom-left"
  | "bottom-center"
  | "bottom-right"
  | "left"
  | "right";

const DEFAULT_HOVER_DIRECTION: HoverDirection = "top-center";

/**
 * Which edge/corner of the trigger the pointer crossed on entry. Drives the
 * direction the block grid wipes in. A pointer that enters within
 * `EDGE_THRESHOLD_RATIO` of the trigger's centre line on the minor axis counts
 * as a straight edge entry; anything further out is a corner entry.
 */
const EDGE_THRESHOLD_RATIO = 0.25;

const resolveHoverDirection = (
  rect: Pick<DOMRect, "left" | "top" | "width" | "height">,
  pointerX: number,
  pointerY: number,
): HoverDirection => {
  const deltaX = pointerX - (rect.left + rect.width / 2);
  const deltaY = pointerY - (rect.top + rect.height / 2);
  const thresholdX = rect.width * EDGE_THRESHOLD_RATIO;
  const thresholdY = rect.height * EDGE_THRESHOLD_RATIO;

  if (Math.abs(deltaX) > Math.abs(deltaY)) {
    if (Math.abs(deltaY) < thresholdY) {
      return deltaX > 0 ? "right" : "left";
    }
    if (deltaY < 0) {
      return deltaX > 0 ? "top-right" : "top-left";
    }
    return deltaX > 0 ? "bottom-right" : "bottom-left";
  }

  if (Math.abs(deltaX) < thresholdX) {
    return deltaY > 0 ? "bottom-center" : "top-center";
  }
  if (deltaX < 0) {
    return deltaY > 0 ? "bottom-left" : "top-left";
  }
  return deltaY > 0 ? "bottom-right" : "top-right";
};

interface TooltipOptions {
  initialOpen?: boolean;
  placement?: Placement;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
}

export const useTooltip = ({
  initialOpen = false,
  placement = "top",
  open: controlledOpen,
  onOpenChange: setControlledOpen,
}: TooltipOptions = {}) => {
  const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen);
  const [hoverDirection, setHoverDirection] = useState<HoverDirection>(DEFAULT_HOVER_DIRECTION);

  const open = controlledOpen ?? uncontrolledOpen;
  const setOpen = useCallback(
    (nextOpen: boolean) => {
      if (controlledOpen === undefined) {
        setUncontrolledOpen(nextOpen);
      }
      setControlledOpen?.(nextOpen);
    },
    [controlledOpen, setControlledOpen],
  );

  const data = useFloating({
    middleware: [
      offset(5),
      flip({
        fallbackAxisSideDirection: "start",
        padding: 5,
      }),
      shift({ padding: 5 }),
    ],
    onOpenChange: (isOpen, event) => {
      const { reference } = data.elements;
      if (isOpen && !open && reference && event instanceof MouseEvent) {
        setHoverDirection(
          resolveHoverDirection(reference.getBoundingClientRect(), event.clientX, event.clientY),
        );
      }
      // Reset direction on close so the next open starts from a known state.
      if (!isOpen) {
        setHoverDirection(DEFAULT_HOVER_DIRECTION);
      }
      setOpen(isOpen);
    },
    open,
    placement,
    strategy: "fixed",
    whileElementsMounted: autoUpdate,
  });

  const { context } = data;

  const hover = useHover(context, {
    enabled: controlledOpen === undefined,
    move: false,
  });

  const focus = useFocus(context, {
    enabled: controlledOpen === undefined,
  });
  const dismiss = useDismiss(context);
  const role = useRole(context, { role: "tooltip" });

  const interactions = useInteractions([hover, focus, dismiss, role]);

  return useMemo(
    () => ({
      hoverDirection,
      open,
      setOpen,
      ...interactions,
      ...data,
    }),
    [open, setOpen, hoverDirection, interactions, data],
  );
};

type TooltipContextValue = ReturnType<typeof useTooltip>;

/** Floating UI reports the position as numbers, but they are not meaningful
 * until the floating element has been measured. */
const isPlaced = ({ x, y }: Pick<TooltipContextValue, "x" | "y">) =>
  x !== null && x !== undefined && y !== null && y !== undefined;

const TooltipContext = createContext<TooltipContextValue | null>(null);

export const useTooltipContext = () => {
  const context = use(TooltipContext);

  if (context === null) {
    throw new Error("Tooltip components must be wrapped in <Tooltip />");
  }

  return context;
};

export const Tooltip = ({ children, ...options }: { children: ReactNode } & TooltipOptions) => {
  const tooltip = useTooltip(options);

  return <TooltipContext.Provider value={tooltip}>{children}</TooltipContext.Provider>;
};

type TooltipTriggerProps = HTMLProps<HTMLElement> &
  (
    | { asChild: true; children: ReactElement<HTMLProps<HTMLElement>> }
    | { asChild?: false; children: ReactNode }
  );

export const TooltipTrigger = ({
  children,
  asChild,
  ref: propRef,
  ...props
}: TooltipTriggerProps) => {
  const context = useTooltipContext();
  const { ref: childRef, ...childProps } = asChild ? children.props : {};
  const ref = useMergeRefs([context.refs.setReference, propRef, childRef]);

  const stateProps = {
    "data-side": context.placement.split("-")[0],
    "data-state": context.open ? "open" : "closed",
  };

  if (asChild) {
    // oxlint-disable-next-line react/refs, react/no-clone-element -- asChild: the trigger props and merged ref are grafted onto the caller's element, never reading ref.current
    return cloneElement(children, {
      ...context.getReferenceProps({ ...props, ...childProps, ...stateProps }),
      ref,
    });
  }

  return (
    <button type="button" ref={ref} {...stateProps} {...context.getReferenceProps(props)}>
      {children}
    </button>
  );
};

const easeInOutQuint = (x: number) =>
  x < 0.5 ? 16 * x * x * x * x * x : 1 - (-2 * x + 2) ** 5 / 2;

const lineTransition = {
  duration: 1,
  ease: easeInOutQuint,
};

const TooltipLines = () => {
  const context = useTooltipContext();
  const floatingEl = context.elements.floating;

  if (!floatingEl || !isPlaced(context)) {
    return null;
  }

  return (
    <>
      <motion.div
        className="line lineH"
        initial={{ opacity: 0, y: -1 }}
        animate={{ opacity: 1, y: context.y }}
        exit={{ opacity: 0, y: -1 }}
        transition={lineTransition}
      />
      <motion.div
        className="line lineH"
        initial={{ opacity: 0, y: "100dvh" }}
        animate={{ opacity: 1, y: context.y + floatingEl.offsetHeight }}
        exit={{ opacity: 0, y: "100dvh" }}
        transition={lineTransition}
      />
      <motion.div
        className="line lineV"
        initial={{ opacity: 0, x: -1 }}
        animate={{ opacity: 1, x: context.x }}
        exit={{ opacity: 0, x: -1 }}
        transition={lineTransition}
      />
      <motion.div
        className="line lineV"
        initial={{ opacity: 0, x: "100dvw" }}
        animate={{ opacity: 1, x: context.x + floatingEl.offsetWidth }}
        exit={{ opacity: 0, x: "100dvw" }}
        transition={lineTransition}
      />
    </>
  );
};

const cols = 11;
const rows = 8;
const duration = 0.07;
const baseDelay = duration / 2;
const blocks = Array.from({ length: cols * rows }, (_, i) => i);
const centerCol = Math.floor(cols / 2);

const calculateDelay = (n: number) => baseDelay * Math.floor(n / cols) + baseDelay * (n % cols);

// Calculate total delay for the default top direction
const totalDelay = calculateDelay(cols * rows);

/**
 * Per-block stagger delay: distance (in grid steps) from the entry edge/corner,
 * scaled by `baseDelay`, so the wipe travels away from where the pointer came in.
 */
const getDirectionalDelay = (n: number, direction: HoverDirection) => {
  const row = Math.floor(n / cols);
  const col = n % cols;
  const fromBottom = rows - 1 - row;
  const fromRight = cols - 1 - col;

  switch (direction) {
    case "left": {
      return baseDelay * col + baseDelay * row;
    }
    case "right": {
      return baseDelay * fromRight + baseDelay * row;
    }
    case "bottom-center": {
      return baseDelay * fromBottom + baseDelay * Math.abs(col - centerCol);
    }
    case "top-left": {
      return baseDelay * row + baseDelay * col;
    }
    case "top-right": {
      return baseDelay * row + baseDelay * fromRight;
    }
    case "bottom-left": {
      return baseDelay * fromBottom + baseDelay * col;
    }
    case "bottom-right": {
      return baseDelay * fromBottom + baseDelay * fromRight;
    }
    case "top-center": {
      return baseDelay * row + baseDelay * Math.abs(col - centerCol);
    }
    default: {
      return 0;
    }
  }
};

// React's CSSProperties has no index signature for custom properties, so widen
// it rather than reaching for a type assertion.
type BlocksContainerStyle = CSSProperties & Record<`--${string}`, number>;

const blocksContainerStyle: BlocksContainerStyle = { "--cols": cols, "--rows": rows };

const TooltipBlocks = () => {
  const context = useTooltipContext();

  if (!isPlaced(context)) {
    return null;
  }

  return (
    <div className="blocksContainer" style={blocksContainerStyle}>
      {blocks.map((i) => (
        <motion.div
          key={i}
          className="block"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{
            delay: getDirectionalDelay(i, context.hoverDirection),
            duration,
          }}
        />
      ))}
    </div>
  );
};

export const TooltipContent = ({
  className,
  type = "default",
  ref: propRef,
  children,
  ...props
}: HTMLProps<HTMLDivElement> & {
  type?: "default" | "block";
}) => {
  const context = useTooltipContext();
  const ref = useMergeRefs([context.refs.setFloating, propRef]);
  // Children stay out of `getFloatingProps`, which merges interaction props and
  // would only hand them back untyped.
  const floatingProps = context.getFloatingProps(props);
  const blockType = type === "block";

  const tooltipMotionProps = blockType
    ? {}
    : {
        animate: { opacity: 1 },
        exit: { opacity: 0 },
        initial: { opacity: 0 },
        transition: { duration: 0.23 },
      };

  const contentMotionProps = blockType
    ? {
        animate: {
          opacity: 1,
          transition: {
            delay: context.hoverDirection.includes("top") ? totalDelay * 2 : totalDelay,
          },
        },
        exit: { opacity: 0 },
        initial: { opacity: 0 },
      }
    : {};

  return (
    <FloatingPortal>
      <AnimatePresence>
        {context.open && (
          <motion.div
            className={`tooltip ${blockType ? "block" : ""} ${className ?? ""}`}
            ref={ref}
            style={context.floatingStyles}
            {...tooltipMotionProps}
            {...floatingProps}
          >
            {blockType && <TooltipBlocks />}
            <motion.div className="content" {...contentMotionProps}>
              {children}
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
      {blockType && <AnimatePresence>{context.open && <TooltipLines />}</AnimatePresence>}
    </FloatingPortal>
  );
};
```
