# Motion

> The easing curves, durations and reduced-motion check every component animates with, plus hooks for size morphs and sliding highlights.

Docs: https://hextaui.com/docs/motion
Markdown: https://hextaui.com/docs/motion.md

```tsx title="components/examples/motion/easing.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  duration,
  easeInOut,
  easeOut,
  easeSpring,
  prefersReducedMotion,
} from "@/lib/motion"

const curves = [
  { name: "easeOut", easing: easeOut },
  { name: "easeInOut", easing: easeInOut },
  { name: "easeSpring", easing: easeSpring },
]

export function MotionEasing() {
  const dots = React.useRef<(HTMLSpanElement | null)[]>([])
  const [forward, setForward] = React.useState(true)

  const play = () => {
    dots.current.forEach((dot) => {
      if (!dot) {
        return
      }
      const track = dot.parentElement?.clientWidth ?? 0
      const distance = track - dot.offsetWidth
      dot.animate(
        [
          { translate: `${forward ? 0 : distance}px 0` },
          { translate: `${forward ? distance : 0}px 0` },
        ],
        {
          duration: prefersReducedMotion() ? 0 : duration.morph * 2,
          easing: curves[dots.current.indexOf(dot)].easing,
          fill: "forwards",
        }
      )
    })
    setForward((value) => !value)
  }

  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      {curves.map((curve, index) => (
        <div key={curve.name} className="flex flex-col gap-1.5">
          <span className="font-mono text-xs text-muted-foreground">
            {curve.name}
          </span>
          <div dir="ltr" className="h-3 rounded-full bg-muted">
            <span
              ref={(node) => {
                dots.current[index] = node
              }}
              className="block size-3 rounded-full bg-foreground"
            />
          </div>
        </div>
      ))}
      <Button variant="outline" size="sm" onClick={play}>
        Play at {duration.morph * 2}ms
      </Button>
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/motion.json
```

This adds the utility and anything it depends on.

### Manual

Copy and paste the following code into your project.

```ts title="lib/motion.ts"
import * as React from "react"

const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"
const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)"
const easeSpring =
  "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)"

const duration = {
  press: 100,
  release: 200,
  hover: 150,
  enter: 200,
  exit: 150,
  morph: 300,
} as const

function prefersReducedMotion() {
  return (
    typeof window === "undefined" ||
    typeof window.matchMedia !== "function" ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

type SizeAxis = "width" | "height"

type SizeMorphOptions = {
  axis: SizeAxis
  enabled?: boolean
  duration?: number
  easing?: string
}

function readSize(element: HTMLElement, axis: SizeAxis) {
  const value = parseFloat(getComputedStyle(element)[axis])
  if (Number.isFinite(value)) {
    return value
  }
  const rect = element.getBoundingClientRect()
  return axis === "width" ? rect.width : rect.height
}

function attachSizeMorph(
  element: HTMLElement,
  axis: SizeAxis,
  time: number,
  easing: string
) {
  if (
    typeof MutationObserver === "undefined" ||
    typeof element.animate !== "function"
  ) {
    return undefined
  }

  let settled = readSize(element, axis)
  let animation: Animation | null = null

  const morph = () => {
    const from = animation ? readSize(element, axis) : settled
    animation?.cancel()
    animation = null
    const to = readSize(element, axis)
    settled = to

    if (
      Math.abs(from - to) < 0.5 ||
      prefersReducedMotion() ||
      !element.isConnected ||
      element.getClientRects().length === 0
    ) {
      element.removeAttribute("data-morphing")
      return
    }

    element.setAttribute("data-morphing", "")
    const running = element.animate(
      [{ [axis]: `${from}px` }, { [axis]: `${to}px` }],
      { duration: time, easing }
    )
    animation = running
    running.onfinish = () => {
      if (animation === running) {
        animation = null
        element.removeAttribute("data-morphing")
        settled = readSize(element, axis)
      }
    }
  }

  const mutations = new MutationObserver(morph)
  mutations.observe(element, {
    childList: true,
    subtree: true,
    characterData: true,
  })

  const resize =
    typeof ResizeObserver === "undefined"
      ? null
      : new ResizeObserver(() => {
          if (!animation) {
            settled = readSize(element, axis)
          }
        })
  resize?.observe(element)

  return () => {
    mutations.disconnect()
    resize?.disconnect()
    animation?.cancel()
    element.removeAttribute("data-morphing")
  }
}

function useSizeMorph<T extends HTMLElement>({
  axis,
  enabled = true,
  duration: time = duration.morph,
  easing = easeOut,
}: SizeMorphOptions): React.RefCallback<T> {
  return React.useCallback(
    (element: T | null) => {
      if (!element || !enabled) {
        return undefined
      }
      return attachSizeMorph(element, axis, time, easing)
    },
    [axis, enabled, time, easing]
  )
}

function useSlidingHighlight(
  barRef: React.RefObject<HTMLElement | null>,
  highlightRef: React.RefObject<HTMLElement | null>,
  selector: string,
  attribute = "data-popup-open"
) {
  React.useLayoutEffect(() => {
    const bar = barRef.current
    const highlight = highlightRef.current
    if (!bar || !highlight) {
      return
    }

    let current: HTMLElement | null = null

    const place = (trigger: HTMLElement, instant: boolean) => {
      if (instant || prefersReducedMotion()) {
        highlight.setAttribute("data-instant", "")
      } else {
        highlight.removeAttribute("data-instant")
      }
      const frame = bar.getBoundingClientRect()
      const box = trigger.getBoundingClientRect()
      const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1
      highlight.style.left = "0px"
      highlight.style.width = `${box.width / scale}px`
      highlight.style.height = `${box.height / scale}px`
      highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)`
    }

    const sync = () => {
      const trigger = bar.querySelector<HTMLElement>(selector)
      if (trigger === current) {
        if (trigger) {
          place(trigger, true)
        }
        return
      }
      const appearing = current === null
      current = trigger
      if (!trigger) {
        highlight.removeAttribute("data-visible")
        return
      }
      place(trigger, appearing)
      if (appearing) {
        void highlight.offsetWidth
      }
      highlight.setAttribute("data-visible", "")
    }

    sync()
    const mutations = new MutationObserver(sync)
    mutations.observe(bar, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: [attribute],
    })
    const resize =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            if (current) {
              place(current, true)
            }
          })
    resize?.observe(bar)
    const onScroll = () => {
      if (current) {
        place(current, true)
      }
    }
    bar.addEventListener("scroll", onScroll, { capture: true, passive: true })

    return () => {
      mutations.disconnect()
      resize?.disconnect()
      bar.removeEventListener("scroll", onScroll, { capture: true })
    }
  }, [barRef, highlightRef, selector, attribute])
}

export {
  duration,
  easeInOut,
  easeOut,
  easeSpring,
  prefersReducedMotion,
  useSizeMorph,
  useSlidingHighlight,
}
export type { SizeMorphOptions }
```

Update the import paths to match your project setup.

## Principles

Every HextaUI component moves with the same few curves and durations, so the library feels like one thing.

- **Ease out for things that respond to you.** Elements entering, expanding or following a click start fast and settle, so the interface feels immediate.
- **Short and interruptible.** Most motion is 150 to 300ms. Anything that can be reversed starts from where it is now rather than restarting.
- **Reduced motion is a second design, not an off switch.** Movement turns into instant changes or plain fades, and state stays readable.

## Easing

The theme defines the curves as Tailwind easing utilities, and `lib/motion` exports the same values for the Web Animations API.

| Class | Description |
| --- | --- |
| `ease-out-quint` | easeOut in JS. The default for movement: popovers, highlights, size changes. |
| `ease-out-cubic` | A softer ease out for color and shadow changes on hover and focus. |
| `ease-in-out-quart` | easeInOut in JS. For movement between two resting states that nobody triggered directly. |
| `ease-spring` | easeSpring in JS. A spring with a small overshoot, written as linear(), for things that land, like a toggle's thumb. |
| `ease-drawer` | The iOS sheet curve for drawers and sheets that slide in from an edge. |

```tsx
<div className="transition-transform duration-300 ease-out-quint motion-reduce:transition-none" />
<div className="transition-colors duration-150 ease-out-cubic" />
<aside className="transition-transform duration-500 ease-drawer" />
```

## Durations

```tsx
import { duration, easeOut, prefersReducedMotion } from "@/lib/motion"

element.animate(
  [{ opacity: 0, translate: "0 4px" }, { opacity: 1, translate: "0 0" }],
  {
    duration: prefersReducedMotion() ? 0 : duration.enter,
    easing: easeOut,
  }
)
```

| duration. | Description |
| --- | --- |
| `press: 100` | Pressed state going down. |
| `release: 200` | Coming back up after a press. |
| `hover: 150` | Hover and focus feedback. |
| `enter: 200` | Elements appearing. |
| `exit: 150` | Elements leaving. Exits are faster than entrances, so they never hold anything up. |
| `morph: 300` | Size and position changes. |

`prefersReducedMotion()` reads the media query at call time. Check it when an animation starts rather than once on mount, so changing the system setting applies right away. It returns `true` on the server.

## Size morphs

```tsx title="components/examples/motion/size-morph.tsx"
"use client"

import * as React from "react"
import { IconCheck, IconCopy } from "@tabler/icons-react"

import { useSizeMorph } from "@/lib/motion"

export function MotionSizeMorph() {
  const [copied, setCopied] = React.useState(false)
  const morphRef = useSizeMorph<HTMLButtonElement>({ axis: "width" })

  React.useEffect(() => {
    if (!copied) {
      return
    }
    const timer = setTimeout(() => setCopied(false), 1600)
    return () => clearTimeout(timer)
  }, [copied])

  return (
    <button
      ref={morphRef}
      type="button"
      onClick={() => setCopied(true)}
      className="inline-flex h-9 items-center gap-1.5 overflow-hidden rounded-md bg-secondary px-3 text-sm font-medium whitespace-nowrap outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden"
    >
      {copied ? (
        <IconCheck className="size-4 shrink-0" />
      ) : (
        <IconCopy className="size-4 shrink-0" />
      )}
      {copied ? "Copied to clipboard" : "Copy"}
    </button>
  )
}
```

```tsx
const morphRef = useSizeMorph<HTMLButtonElement>({ axis: "width" })

<button ref={morphRef} className="overflow-hidden whitespace-nowrap">
  {copied ? "Copied to clipboard" : "Copy"}
</button>
```

- Any DOM change inside the element triggers a morph, whether text, children or icons. Size changes from outside, like a resize, don't, so the element follows its container without lag.
- A change mid-morph continues from the current size. While it runs, the element has `data-morphing`, which you can use to clip overflow or pause other transitions.
- Keep the element at its natural size: no fixed width or height on the animated axis. Add `overflow-hidden` so the new content doesn't spill out while it grows.
- It returns a callback ref. Combine it with other refs using `useMergedRef`.

## Sliding highlights

```tsx title="components/examples/motion/sliding-highlight.tsx"
"use client"

import * as React from "react"

import { useSlidingHighlight } from "@/lib/motion"

const views = ["Overview", "Activity", "Settings", "Billing"]

export function MotionSlidingHighlight() {
  const [view, setView] = React.useState(views[0])
  const barRef = React.useRef<HTMLDivElement>(null)
  const highlightRef = React.useRef<HTMLSpanElement>(null)
  useSlidingHighlight(barRef, highlightRef, "[data-active]", "data-active")

  return (
    <div
      ref={barRef}
      role="tablist"
      aria-label="Views"
      className="relative isolate flex rounded-lg bg-muted p-1"
    >
      <span
        ref={highlightRef}
        aria-hidden="true"
        className="pointer-events-none absolute top-0 -z-1 rounded-md bg-background opacity-0 transition-all duration-300 ease-out-quint data-instant:transition-opacity data-visible:opacity-100 motion-reduce:transition-opacity"
      />
      {views.map((item) => (
        <button
          key={item}
          type="button"
          role="tab"
          aria-selected={item === view}
          data-active={item === view ? "" : undefined}
          onClick={() => setView(item)}
          className="h-8 rounded-md px-3 text-sm text-muted-foreground transition-colors duration-150 outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden data-active:text-foreground"
        >
          {item}
        </button>
      ))}
    </div>
  )
}
```

```tsx
const barRef = React.useRef<HTMLDivElement>(null)
const highlightRef = React.useRef<HTMLSpanElement>(null)
useSlidingHighlight(barRef, highlightRef, "[data-active]", "data-active")

<div ref={barRef} className="relative isolate flex">
  <span
    ref={highlightRef}
    aria-hidden="true"
    className="absolute top-0 -z-1 opacity-0 transition-all duration-300 ease-out-quint data-instant:transition-opacity data-visible:opacity-100"
  />
  {items}
</div>
```

- The highlight is sized and translated with inline styles. Give it `absolute top-0` and a transition on `transform`, `width`, `height` and `opacity`.
- The hook watches the attribute you name with a `MutationObserver`, so it follows state from anywhere, including Base UI's own `data-pressed`, `data-checked` or `aria-current`.
- `data-visible` is set while something matches. `data-instant` is set when the highlight should jump: on first appearance, on resize and scroll, and under reduced motion. Style it as `data-instant:transition-opacity`.
- It measures with the bar's scale in mind, so it stays aligned inside a dialog that's still zooming in.

## API reference

### useSizeMorph(options)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `axis` | `"width" \| "height"` | – | Which dimension to animate. |
| `enabled` | `boolean` | `true` | Whether to animate. |
| `duration` | `number` | `300` | Milliseconds. |
| `easing` | `string` | `easeOut` | Any CSS easing. |

### useSlidingHighlight(barRef, highlightRef, selector, attribute?)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `barRef` | `RefObject<HTMLElement \| null>` | – | The positioned container. |
| `highlightRef` | `RefObject<HTMLElement \| null>` | – | The element to move. |
| `selector` | `string` | – | Matches the child to highlight. |
| `attribute` | `string` | `"data-popup-open"` | The attribute whose changes move the highlight. |

### Constants

| Export | Description |
| --- | --- |
| `easeOut` | cubic-bezier(0.23, 1, 0.32, 1) |
| `easeInOut` | cubic-bezier(0.77, 0, 0.175, 1) |
| `easeSpring` | A linear() spring. |
| `duration` | press, release, hover, enter, exit and morph. |
| `prefersReducedMotion()` | Whether reduced motion is on. true on the server. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
