HextaUI

Motion

Die Easing-Kurven, Dauern und der Reduced-Motion-Check, mit denen jede Komponente animiert, plus Hooks für Größen-Morphs und gleitende Hervorhebungen.

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

Fügt das Utility und alles, wovon es abhängt, zu deinem Projekt hinzu.

Jede HextaUI-Komponente bewegt sich mit denselben wenigen Kurven und Dauern, sodass sich die Bibliothek wie aus einem Guss anfühlt.

  • Ease out für alles, was auf dich reagiert. Elemente, die erscheinen, sich ausdehnen oder einem Klick folgen, starten schnell und beruhigen sich, sodass sich die Oberfläche unmittelbar anfühlt.
  • Kurz und unterbrechbar. Die meiste Bewegung dauert 150 bis 300ms. Alles Umkehrbare startet dort, wo es gerade ist, statt neu anzufangen.
  • Reduzierte Bewegung ist ein zweites Design, kein Ausschalter. Bewegung wird zu sofortigen Änderungen oder einfachen Fades, und der Zustand bleibt lesbar.

Das Theme definiert die Kurven als Tailwind-Easing-Utilities, und lib/motion exportiert dieselben Werte für die Web Animations API.

KlasseBeschreibung
ease-out-quinteaseOut in JS. Der Standard für Bewegung: Popovers, Highlights, Größenänderungen.
ease-out-cubicEin weicheres Ease out für Farb- und Schattenänderungen bei Hover und Fokus.
ease-in-out-quarteaseInOut in JS. Für Bewegung zwischen zwei Ruhezuständen, die niemand direkt ausgelöst hat.
ease-springeaseSpring in JS. Eine Feder mit leichtem Überschwingen, als linear() geschrieben, für Dinge, die landen, etwa den Thumb eines Toggles.
ease-drawerDie iOS-Sheet-Kurve für Drawer und Sheets, die von einem Rand hereingleiten.
<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.Beschreibung
press: 100Gedrückter Zustand beim Herunterdrücken.
release: 200Zurückkommen nach einem Druck.
hover: 150Feedback bei Hover und Fokus.
enter: 200Erscheinende Elemente.
exit: 150Verschwindende Elemente. Exits sind schneller als Entrances, damit sie nie etwas aufhalten.
morph: 300Änderungen von Größe und Position.

prefersReducedMotion() liest die Media Query beim Aufruf. Prüfe sie, wenn eine Animation startet, statt nur einmal beim Mount, damit eine geänderte Systemeinstellung sofort greift. Auf dem Server gibt es true zurück.

"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>
  • Jede DOM-Änderung im Element löst einen Morph aus, ob Text, Kinder oder Icons. Größenänderungen von außen, etwa durch Resize, tun das nicht, sodass das Element seinem Container ohne Verzögerung folgt.
  • Eine Änderung mitten im Morph läuft von der aktuellen Größe weiter. Solange er läuft, hat das Element data-morphing, womit du Überlauf abschneiden oder andere Übergänge pausieren kannst.
  • Lass das Element in seiner natürlichen Größe: keine feste Breite oder Höhe auf der animierten Achse. Füge overflow-hidden hinzu, damit neuer Inhalt beim Wachsen nicht herausragt.
  • Es gibt eine Callback-Ref zurück. Kombiniere sie mit anderen Refs über 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>
  • Das Highlight wird per Inline-Styles dimensioniert und verschoben. Gib ihm absolute top-0 und einen Übergang auf transform, width, height und opacity.
  • Der Hook beobachtet das genannte Attribut mit einem MutationObserver und folgt so dem Zustand von überall, auch Base UIs eigenem data-pressed, data-checked oder aria-current.
  • data-visible ist gesetzt, solange etwas passt. data-instant ist gesetzt, wenn das Highlight springen soll: beim ersten Erscheinen, bei Resize und Scroll sowie bei reduzierter Bewegung. Style es als data-instant:transition-opacity.
  • Es misst unter Berücksichtigung der Skalierung der Leiste und bleibt so ausgerichtet, auch in einem Dialog, der noch hineinzoomt.
PropTypStandard
axisWelche Dimension animiert wird.
"width" | "height"–
enabledOb animiert werden soll.
booleantrue
durationMillisekunden.
number300
easingBeliebiges CSS-Easing.
stringeaseOut
PropTypStandard
barRefDer positionierte Container.
RefObject<HTMLElement | null>–
highlightRefDas Element, das bewegt wird.
RefObject<HTMLElement | null>–
selectorPasst zum Kind, das hervorgehoben werden soll.
string–
attributeDas Attribut, dessen Änderungen das Highlight bewegen.
string"data-popup-open"
ExportBeschreibung
easeOutcubic-bezier(0.23, 1, 0.32, 1)
easeInOutcubic-bezier(0.77, 0, 0.175, 1)
easeSpringEine linear()-Feder.
durationpress, release, hover, enter, exit und morph.
prefersReducedMotion()Ob reduzierte Bewegung aktiv ist. Auf dem Server true.