HextaUI

Motion

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

easeOut
easeInOut
easeSpring
"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>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/motion.json

Adds the utility and anything it depends on to your project.

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.

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

ClassDescription
ease-out-quinteaseOut in JS. The default for movement: popovers, highlights, size changes.
ease-out-cubicA softer ease out for color and shadow changes on hover and focus.
ease-in-out-quarteaseInOut in JS. For movement between two resting states that nobody triggered directly.
ease-springeaseSpring in JS. A spring with a small overshoot, written as linear(), for things that land, like a toggle's thumb.
ease-drawerThe iOS sheet curve for drawers and sheets that slide in from an edge.
<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" />
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: 100Pressed state going down.
release: 200Coming back up after a press.
hover: 150Hover and focus feedback.
enter: 200Elements appearing.
exit: 150Elements leaving. Exits are faster than entrances, so they never hold anything up.
morph: 300Size 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.

"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>
  )
}
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.
"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>
  )
}
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.
PropTypeDefault
axisWhich dimension to animate.
"width" | "height"–
enabledWhether to animate.
booleantrue
durationMilliseconds.
number300
easingAny CSS easing.
stringeaseOut
PropTypeDefault
barRefThe positioned container.
RefObject<HTMLElement | null>–
highlightRefThe element to move.
RefObject<HTMLElement | null>–
selectorMatches the child to highlight.
string–
attributeThe attribute whose changes move the highlight.
string"data-popup-open"
ExportDescription
easeOutcubic-bezier(0.23, 1, 0.32, 1)
easeInOutcubic-bezier(0.77, 0, 0.175, 1)
easeSpringA linear() spring.
durationpress, release, hover, enter, exit and morph.
prefersReducedMotion()Whether reduced motion is on. true on the server.