HextaUI

モーション

すべてのコンポーネントがアニメーションに使うイージングカーブ、継続時間、モーション軽減のチェックと、サイズのモーフィングやスライドするハイライト用のフックです。

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

ユーティリティと、それが依存するすべてをプロジェクトに追加します。

HextaUI のすべてのコンポーネントは同じ少数のカーブとデュレーションで動くため、ライブラリ全体が1つのものとして感じられます。

  • 操作に応える動きにはイーズアウト。登場、展開、クリックに続く要素は速く始まって落ち着くので、インターフェースが即座に反応するように感じられます。
  • 短く、途中で中断できる。ほとんどの動きは150〜300msです。元に戻せるものは、最初からやり直すのではなく現在の状態から始まります。
  • 視差効果の軽減は、オフスイッチではなく2つ目のデザインです。動きは瞬時の変化や単純なフェードに置き換わり、状態は読み取れる状態に保たれます。

テーマはカーブを Tailwind のイージングユーティリティとして定義し、lib/motion は同じ値を Web Animations API 向けにエクスポートします。

クラス説明
ease-out-quintJS では easeOut。動きのデフォルトです。popover、ハイライト、サイズ変化に使います。
ease-out-cubicホバーとフォーカス時の色や影の変化向けの、より柔らかいイーズアウト。
ease-in-out-quartJS では easeInOut。誰も直接トリガーしていない、2つの静止状態間の動き向けです。
ease-springJS では easeSpring。linear() で記述した、わずかにオーバーシュートするスプリングで、トグルのつまみのように着地するものに使います。
ease-drawer端からスライドインする drawer と sheet 向けの iOS シートカーブ。
<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。説明
press: 100押し込まれていく状態。
release: 200押した後に戻る動き。
hover: 150ホバーとフォーカスのフィードバック。
enter: 200要素の出現。
exit: 150要素の退場。退場は登場より速く、何かを待たせることがありません。
morph: 300サイズと位置の変化。

prefersReducedMotion() は呼び出し時にメディアクエリを読み取ります。システム設定の変更がすぐに反映されるよう、マウント時に一度ではなく、アニメーションの開始時に確認してください。サーバーでは true を返します。

"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>
  • 要素内のあらゆる DOM の変化(テキスト、子要素、アイコン)がモーフィングを引き起こします。リサイズのような外部からのサイズ変化は引き起こさないので、要素は遅延なくコンテナーに追従します。
  • モーフィングの途中で変更があっても、現在のサイズから続行します。実行中は要素に data-morphing が付くので、オーバーフローのクリップや他のトランジションの一時停止に使えます。
  • 要素は本来のサイズのままにしてください。アニメーションさせる軸に固定の幅や高さを指定しないでください。新しいコンテンツが伸びている間にあふれないよう、overflow-hidden を追加します。
  • コールバック ref を返します。他の ref と組み合わせるには 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>
  • ハイライトのサイズと位置はインラインスタイルで設定されます。absolute top-0 を指定し、transform、width、height、opacity にトランジションを付けてください。
  • このフックは、指定した属性を MutationObserver で監視するため、Base UI 自身の data-pressed、data-checked、aria-current を含め、どこからの状態にも追従します。
  • data-visible は何かが一致している間設定されます。data-instant はハイライトが瞬時に移動すべきときに設定されます。初回表示時、リサイズやスクロール時、視差効果の軽減が有効なときです。data-instant:transition-opacity のようにスタイルを付けます。
  • バーのスケールを考慮して計測するため、まだズームインしている最中の dialog 内でも位置が揃ったままです。
プロパティ型デフォルト
axisアニメーションさせる寸法。
"width" | "height"–
enabledアニメーションするかどうか。
booleantrue
durationミリ秒。
number300
easing任意の CSS イージング。
stringeaseOut
プロパティ型デフォルト
barRef位置指定されたコンテナー。
RefObject<HTMLElement | null>–
highlightRef動かす要素。
RefObject<HTMLElement | null>–
selectorハイライト対象の子要素に一致させます。
string–
attribute変化したときにハイライトを動かす属性。
string"data-popup-open"
エクスポート説明
easeOutcubic-bezier(0.23, 1, 0.32, 1)
easeInOutcubic-bezier(0.77, 0, 0.175, 1)
easeSpringlinear() によるスプリング。
durationpress、release、hover、enter、exit、morph。
prefersReducedMotion()視差効果の軽減が有効かどうか。サーバーでは true です。