HextaUI

Mouvement

Les courbes d’easing, les durées et la vérification de réduction des animations utilisées par chaque composant, ainsi que des hooks pour les morphs de taille et les surlignages glissants.

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

Ajoute l’utilitaire et tout ce dont il dépend à votre projet.

Chaque composant HextaUI se déplace avec les mêmes quelques courbes et durées, si bien que la bibliothèque forme un tout cohérent.

  • Ease out pour ce qui répond à une action. Les éléments qui entrent, se déploient ou suivent un clic démarrent vite puis se stabilisent, pour que l'interface semble immédiate.
  • Court et interruptible. La plupart des animations durent de 150 à 300ms. Tout ce qui peut être inversé repart de son état actuel au lieu de recommencer.
  • La réduction des animations est un second design, pas un interrupteur. Le mouvement devient un changement instantané ou un simple fondu, et l'état reste lisible.

Le thème définit les courbes comme utilitaires d'easing Tailwind, et lib/motion exporte les mêmes valeurs pour la Web Animations API.

ClasseDescription
ease-out-quinteaseOut en JS. La valeur par défaut du mouvement : popovers, surbrillances, changements de taille.
ease-out-cubicUn ease out plus doux pour les changements de couleur et d'ombre au survol et au focus.
ease-in-out-quarteaseInOut en JS. Pour un mouvement entre deux états de repos que personne n'a déclenché directement.
ease-springeaseSpring en JS. Un ressort avec un léger dépassement, écrit en linear(), pour ce qui atterrit, comme le curseur d'un toggle.
ease-drawerLa courbe de feuille iOS pour les drawers et sheets qui glissent depuis un bord.
<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: 100État pressé, à l'enfoncement.
release: 200Le retour vers le haut après un appui.
hover: 150Retour visuel au survol et au focus.
enter: 200Éléments qui apparaissent.
exit: 150Éléments qui disparaissent. Les sorties sont plus rapides que les entrées, pour ne jamais rien retarder.
morph: 300Changements de taille et de position.

prefersReducedMotion() lit la media query au moment de l'appel. Vérifiez-la au démarrage d'une animation plutôt qu'une seule fois au montage, pour que le changement du réglage système s'applique tout de suite. Elle renvoie true côté serveur.

"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>
  • Toute modification du DOM à l'intérieur de l'élément déclenche un morphing, qu'il s'agisse de texte, d'enfants ou d'icônes. Les changements de taille venus de l'extérieur, comme un redimensionnement, n'en déclenchent pas : l'élément suit son conteneur sans retard.
  • Un changement en plein morphing repart de la taille courante. Pendant l'animation, l'élément porte data-morphing, que vous pouvez utiliser pour couper le débordement ou suspendre d'autres transitions.
  • Gardez l'élément à sa taille naturelle : aucune largeur ni hauteur fixe sur l'axe animé. Ajoutez overflow-hidden pour que le nouveau contenu ne déborde pas pendant la croissance.
  • Il renvoie une ref callback. Combinez-la avec d'autres refs grâce à 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>
  • La surbrillance est dimensionnée et translatée avec des styles en ligne. Donnez-lui absolute top-0 et une transition sur transform, width, height et opacity.
  • Le hook observe l'attribut que vous nommez avec un MutationObserver, et suit donc un état venu de n'importe où, y compris les data-pressed, data-checked ou aria-current de Base UI.
  • data-visible est défini tant que quelque chose correspond. data-instant est défini quand la surbrillance doit sauter : à la première apparition, au redimensionnement et au défilement, et avec la réduction des animations. Stylez-le avec data-instant:transition-opacity.
  • Il mesure en tenant compte de l'échelle de la barre, et reste donc aligné dans un dialog qui est encore en train de zoomer.
PropTypePar défaut
axisQuelle dimension animer.
"width" | "height"–
enabledIndique s'il faut animer.
booleantrue
durationMillisecondes.
number300
easingN'importe quel easing CSS.
stringeaseOut
PropTypePar défaut
barRefLe conteneur positionné.
RefObject<HTMLElement | null>–
highlightRefL'élément à déplacer.
RefObject<HTMLElement | null>–
selectorCorrespond à l'enfant à mettre en surbrillance.
string–
attributeL'attribut dont les changements déplacent la surbrillance.
string"data-popup-open"
ExportDescription
easeOutcubic-bezier(0.23, 1, 0.32, 1)
easeInOutcubic-bezier(0.77, 0, 0.175, 1)
easeSpringUn ressort linear().
durationpress, release, hover, enter, exit et morph.
prefersReducedMotion()Indique si la réduction des animations est activée. true côté serveur.