HextaUI

Movimiento

Las curvas de easing, duraciones y la comprobación de movimiento reducido con las que se anima cada componente, además de hooks para transformaciones de tamaño y resaltados deslizantes.

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

Añade la utilidad y todo aquello de lo que depende a tu proyecto.

Todos los componentes de HextaUI se mueven con las mismas pocas curvas y duraciones, de modo que la librería se siente como una sola cosa.

  • Ease out para lo que responde a ti. Los elementos que entran, se expanden o siguen a un clic empiezan rápido y se asientan, para que la interfaz se sienta inmediata.
  • Corto e interrumpible. La mayor parte del movimiento dura de 150 a 300ms. Todo lo que se puede revertir parte de donde está ahora en lugar de reiniciarse.
  • El movimiento reducido es un segundo diseño, no un interruptor de apagado. El movimiento se convierte en cambios instantáneos o fundidos simples, y el estado sigue siendo legible.

El tema define las curvas como utilidades de easing de Tailwind, y lib/motion exporta los mismos valores para la Web Animations API.

ClaseDescripción
ease-out-quinteaseOut en JS. El valor por defecto para el movimiento: popovers, resaltados, cambios de tamaño.
ease-out-cubicUn ease out más suave para los cambios de color y sombra al pasar el cursor y al enfocar.
ease-in-out-quarteaseInOut en JS. Para el movimiento entre dos estados de reposo que nadie provocó directamente.
ease-springeaseSpring en JS. Un spring con un pequeño rebote, escrito como linear(), para cosas que aterrizan, como el thumb de un toggle.
ease-drawerLa curva de sheet de iOS para drawers y sheets que se deslizan desde un borde.
<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.Descripción
press: 100Estado pulsado al bajar.
release: 200Volver a subir después de una pulsación.
hover: 150Feedback al pasar el cursor y al enfocar.
enter: 200Elementos que aparecen.
exit: 150Elementos que se van. Las salidas son más rápidas que las entradas, así que nunca retienen nada.
morph: 300Cambios de tamaño y de posición.

prefersReducedMotion() lee la media query en el momento de la llamada. Compruébalo cuando empiece una animación en lugar de una sola vez al montar, para que un cambio en el ajuste del sistema se aplique de inmediato. Devuelve true en el servidor.

"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>
  • Cualquier cambio del DOM dentro del elemento dispara un morph, ya sea texto, hijos o iconos. Los cambios de tamaño desde fuera, como un resize, no lo hacen, así que el elemento sigue a su contenedor sin retraso.
  • Un cambio a mitad de un morph continúa desde el tamaño actual. Mientras se ejecuta, el elemento tiene data-morphing, que puedes usar para recortar el desbordamiento o pausar otras transiciones.
  • Mantén el elemento en su tamaño natural: sin ancho ni alto fijos en el eje animado. Añade overflow-hidden para que el contenido nuevo no se desborde mientras crece.
  • Devuelve un ref de callback. Combínalo con otros refs usando 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>
  • El resaltado se dimensiona y se traslada con estilos en línea. Dale absolute top-0 y una transición sobre transform, width, height y opacity.
  • El hook observa el atributo que indiques con un MutationObserver, así que sigue el estado desde cualquier lugar, incluidos los data-pressed, data-checked o aria-current propios de Base UI.
  • data-visible se establece mientras algo coincide. data-instant se establece cuando el resaltado debe saltar: en la primera aparición, al cambiar de tamaño y al hacer scroll, y con movimiento reducido. Dale estilo como data-instant:transition-opacity.
  • Mide teniendo en cuenta la escala de la barra, así que permanece alineado dentro de un dialog que todavía está haciendo zoom de entrada.
PropTipoPredeterminado
axisQué dimensión animar.
"width" | "height"–
enabledSi se debe animar.
booleantrue
durationMilisegundos.
number300
easingCualquier easing de CSS.
stringeaseOut
PropTipoPredeterminado
barRefEl contenedor posicionado.
RefObject<HTMLElement | null>–
highlightRefEl elemento que se va a mover.
RefObject<HTMLElement | null>–
selectorCoincide con el hijo que se va a resaltar.
string–
attributeEl atributo cuyos cambios mueven el resaltado.
string"data-popup-open"
ExportaciónDescripción
easeOutcubic-bezier(0.23, 1, 0.32, 1)
easeInOutcubic-bezier(0.77, 0, 0.175, 1)
easeSpringUn spring con linear().
durationpress, release, hover, enter, exit y morph.
prefersReducedMotion()Si el movimiento reducido está activado. true en el servidor.