Skip to component content
Behavior120 / 155

Animation

Toggle a themed opacity and scale transition without unmounting content.

Example
Campaign draft
Source
Animation.tsxtsx
import { useState } from "react";
import { Animation, Button, GroupBox, HStack, Picker, Text, TextField, Toggle, VStack } from "@/ohmygui";

const effects = ["fade", "slide", "scale", "rotate", "flip", "reveal", "collapse", "bounce", "shake", "pulse", "float"] as const;
const exits = ["fade", "slide", "scale", "collapse"] as const;
const directions = ["up", "down", "left", "right"] as const;
const motionTokens = ["gentle", "snappy", "spring"] as const;
const options = (values: readonly string[]) => values.map((value) => ({ value, label: value }));

export default function Example() {
  const [visible, setVisible] = useState(true);
  const [draft, setDraft] = useState("Ship the spring collection");
  const [effect, setEffect] = useState<(typeof effects)[number]>("slide");
  const [exit, setExit] = useState<(typeof exits)[number]>("fade");
  const [direction, setDirection] = useState<(typeof directions)[number]>("up");
  const [motionToken, setMotionToken] = useState<(typeof motionTokens)[number]>("gentle");
  const [duration, setDuration] = useState("420");
  const [unmountOnExit, setUnmountOnExit] = useState(false);
  const [replayKey, setReplayKey] = useState(0);
  const [message, setMessage] = useState("Animation ready.");

  return (
    <VStack gap="md" align="stretch">
      <HStack gap="md" wrap>
        <Picker label="Entrance" value={effect} onChange={(event) => setEffect(event.currentTarget.value as typeof effect)} items={options(effects)} />
        <Picker label="Exit" value={exit} onChange={(event) => setExit(event.currentTarget.value as typeof exit)} items={options(exits)} />
        <Picker label="Direction" value={direction} onChange={(event) => setDirection(event.currentTarget.value as typeof direction)} items={options(directions)} />
        <Picker label="Motion feel" value={motionToken} onChange={(event) => setMotionToken(event.currentTarget.value as typeof motionToken)} items={options(motionTokens)} />
        <Picker label="Duration" value={duration} onChange={(event) => setDuration(event.currentTarget.value)} items={[{ value: "220", label: "220 ms" }, { value: "420", label: "420 ms" }, { value: "700", label: "700 ms" }]} />
      </HStack>

      <HStack gap="sm" align="center" wrap>
        <Button onClick={() => setVisible((current) => !current)}>{visible ? "Play exit" : "Show again"}</Button>
        <Button variant="outline" onClick={() => { setVisible(true); setReplayKey((key) => key + 1); }}>Replay entrance</Button>
        <Toggle label="Unmount after exit" checked={unmountOnExit} onChange={(event) => setUnmountOnExit(event.currentTarget.checked)} />
      </HStack>

      <Animation
        visible={visible}
        effect={effect}
        exit={exit}
        direction={direction}
        motionToken={motionToken}
        duration={Number(duration)}
        replayKey={replayKey}
        unmountOnExit={unmountOnExit}
        onEnterComplete={() => setMessage("Entrance finished.")}
        onExitComplete={() => setMessage("Exit finished.")}
      >
        <GroupBox title="Campaign draft">
          <TextField label="Headline" value={draft} onChange={(event) => setDraft(event.currentTarget.value)} />
        </GroupBox>
      </Animation>

      <Text as="p" variant="metadata" role="status" aria-live="polite">{message} The draft stays editable across transitions.</Text>
    </VStack>
  );
}