HextaUI

Movimento

As curvas de easing, as durações e a verificação de movimento reduzido com que todo componente anima, além de hooks para transformações de tamanho e destaques 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

Adiciona o utilitário e tudo de que ele depende ao seu projeto.

Todo componente do HextaUI se move com as mesmas poucas curvas e durações, então a biblioteca parece uma coisa só.

  • Ease out para o que responde a você. Elementos que entram, expandem ou seguem um clique começam rápido e desaceleram, para a interface parecer imediata.
  • Curto e interrompível. A maior parte do movimento dura de 150 a 300ms. Tudo que pode ser revertido parte de onde está agora em vez de recomeçar.
  • Movimento reduzido é um segundo design, não um botão de desligar. O movimento vira mudanças instantâneas ou fades simples, e o estado continua legível.

O tema define as curvas como utilitários de easing do Tailwind, e lib/motion exporta os mesmos valores para a Web Animations API.

ClasseDescrição
ease-out-quinteaseOut em JS. O padrão para movimento: popovers, destaques, mudanças de tamanho.
ease-out-cubicUm ease out mais suave para mudanças de cor e sombra em hover e foco.
ease-in-out-quarteaseInOut em JS. Para movimento entre dois estados de repouso que ninguém disparou diretamente.
ease-springeaseSpring em JS. Uma mola com pequeno overshoot, escrita como linear(), para coisas que pousam, como o thumb de um toggle.
ease-drawerA curva de sheet do iOS para drawers e sheets que deslizam a partir de uma borda.
<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.Descrição
press: 100Estado pressionado descendo.
release: 200Voltando depois de um pressionamento.
hover: 150Feedback de hover e foco.
enter: 200Elementos aparecendo.
exit: 150Elementos saindo. As saídas são mais rápidas que as entradas, para nunca segurarem nada.
morph: 300Mudanças de tamanho e posição.

prefersReducedMotion() lê a media query no momento da chamada. Verifique quando a animação começa em vez de uma única vez na montagem, para que a mudança da configuração do sistema valha de imediato. Retorna true no 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>
  • Qualquer mudança no DOM dentro do elemento dispara um morph, seja texto, filhos ou ícones. Mudanças de tamanho vindas de fora, como um resize, não disparam, então o elemento acompanha seu contêiner sem atraso.
  • Uma mudança no meio do morph continua a partir do tamanho atual. Enquanto ele roda, o elemento tem data-morphing, que você pode usar para cortar o overflow ou pausar outras transições.
  • Mantenha o elemento no tamanho natural: sem largura ou altura fixa no eixo animado. Adicione overflow-hidden para o novo conteúdo não vazar enquanto cresce.
  • Retorna uma ref de callback. Combine-a com outras 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>
  • O destaque recebe tamanho e translação por estilos inline. Dê a ele absolute top-0 e uma transição em transform, width, height e opacity.
  • O hook observa o atributo que você nomeia com um MutationObserver, então acompanha o estado de qualquer lugar, incluindo data-pressed, data-checked ou aria-current do próprio Base UI.
  • data-visible é definido enquanto algo corresponde. data-instant é definido quando o destaque deve saltar: na primeira aparição, em resize e scroll, e com movimento reduzido. Estilize como data-instant:transition-opacity.
  • Ele mede levando em conta a escala da barra, então permanece alinhado dentro de um dialog que ainda está ampliando.
PropTipoPadrão
axisQual dimensão animar.
"width" | "height"–
enabledSe deve animar.
booleantrue
durationMilissegundos.
number300
easingQualquer easing CSS.
stringeaseOut
PropTipoPadrão
barRefO contêiner posicionado.
RefObject<HTMLElement | null>–
highlightRefO elemento a mover.
RefObject<HTMLElement | null>–
selectorCorresponde ao filho a destacar.
string–
attributeO atributo cujas mudanças movem o destaque.
string"data-popup-open"
ExportaçãoDescrição
easeOutcubic-bezier(0.23, 1, 0.32, 1)
easeInOutcubic-bezier(0.77, 0, 0.175, 1)
easeSpringUma mola linear().
durationpress, release, hover, enter, exit e morph.
prefersReducedMotion()Se o movimento reduzido está ativado. true no servidor.