How karaUI moves

Animate with a reason.

The checklist we use inside karaUI: ask whether an element should move at all, pick its curve from the shared tokens, keep it short, and write the reduced-motion version at the same time.

Decision framework

Answer these before you animate

Most motion problems are settled by a few questions asked up front, well before anyone tweaks a duration.

01

How often will people see it?

Something clicked dozens of times per session has to respond at once. Keep the flourish for screens that come up once in a while, like onboarding or an empty state.

A toolbar button gets a quick dip. A first-run welcome can afford a longer entrance.

02

What job does it do?

Good reasons to move: showing where a panel came from, acknowledging a tap, signalling a new state, or easing a jump in content. Looking nice is not on the list.

No job, no animation. Delete it and check whether anyone misses it.

03

Which curve fits the action?

Things arriving get ease-out. Things travelling across the screen get ease-in-out. Progress bars run linear. Anything the pointer drags or flicks gets a spring.

Pick the curve from what the element is doing; habit is a poor guide.

04

What happens with reduced motion?

When the OS asks for less motion, fades and color changes can stay. Slides, zooms and parallax should go.

Plan this variant up front, alongside the full version.

Motion tokens

Shared tokens, not one-off curves

Every karaUI component pulls its easing and springs from lib/ease.ts. Pick a token by what the element is doing; the library then feels consistent even though each piece moves differently.

Compare the curves

Both dots travel the same track in the same time.

EASE_OUT[0.16, 1, 0.3, 1]

Starts fast and coasts to a stop, which suits elements arriving or leaving.

EASE_IN_OUT[0.77, 0, 0.175, 1]

Speeds up, then slows down, which suits an element moving between two spots.

SPRING_PRESS

Short and firm; used on buttons and anything else you press.

SPRING_LAYOUT

For pills, indicators and shared surfaces that glide between positions.

Timing

How long should it take?

Bigger surfaces and longer distances need more time; frequent actions need less. Treat the ranges below as a first guess and adjust by eye.

Press feedback100–160msFelt under the finger
Tooltip / popover125–200msGrows out of its anchor
Dropdown / select150–250msOpen before the eye arrives
Modal / drawer200–500msLong enough to show where it came from
Marketing demoFlexibleReadable first, quick second

Most UI motion in karaUI finishes inside 300ms. Go longer only for a demo that teaches something, a gesture the user is driving, or a big change in layout.

Recipes

Five recipes from the library

Each card shows a live demo, the reason the motion exists, a common way to get it wrong, and the code with its reduced-motion branch.

Feedback

Press feedback

Shrink the control slightly the moment it is pressed, so the user knows the tap landed even if the work behind it takes a while.

PurposeTells the user their tap registered.
AvoidDeep shrinks, sluggish rebounds, and nested children that each bounce on their own.
View recipe+
import { motion, useReducedMotion } from "motion/react";
import { SPRING_PRESS } from "@/lib/ease";

const reduce = useReducedMotion();

<motion.button
  whileTap={reduce ? undefined : { scale: 0.97 }}
  transition={SPRING_PRESS}
>
  Continue
</motion.button>
Meaning

Semantic icon motion

Move an icon the way its real-world object would: a bell rocks from the top, a download arrow dips toward the tray below it.

PurposeHints at the action without needing extra text.
AvoidOne stock wiggle reused for every icon, or hover motion firing on touch screens.
View recipe+
const reduce = useReducedMotion();
const canHover = useHoverCapable();

<motion.span
  style={{ transformOrigin: "top center" }}
  animate={active && canHover && !reduce
    ? { rotate: [0, 12, -8, 4, 0] }
    : { rotate: 0 }}
  transition={{ duration: 0.28, ease: EASE_OUT }}
>
  <Bell />
</motion.span>
Entrance

Content reveal

Bring in the one surface that matters with a small upward drift and a light blur, and let it settle before the user starts reading it.

New surface

Ready before focus.

PurposeStops new content from popping in as a hard cut.
AvoidEntrances that run for half a second, or staggering every tiny child one by one.
View recipe+
const reduce = useReducedMotion();

<motion.div
  initial={{
    opacity: 0,
    transform: reduce ? "none" : "translateY(8px)",
    filter: reduce ? "none" : "blur(4px)",
  }}
  animate={{
    opacity: 1,
    transform: "translateY(0px)",
    filter: "blur(0px)",
  }}
  transition={{ duration: 0.22, ease: EASE_OUT }}
/>
Movement

Layout continuity

When a compact control grows, keep the original shape on screen and resize it; only once there is room should the label fade in.

PurposeThe user can see it is still the same button, just bigger.
AvoidAnimating width directly, or showing text before the container has grown to fit it.
View recipe+
<motion.div layout transition={CONTINUITY_SPRING}>
  <motion.button layout transition={CONTINUITY_SPRING}>
    <motion.span layout="position">
      <PanelTopOpen />
    </motion.span>

    <AnimatePresence mode="popLayout" initial={false}>
      {expanded ? (
        <motion.span
          layout
          variants={CONTINUITY_LABEL}
          initial="hidden"
          animate="visible"
          exit="exit"
          transition={CONTINUITY_SPRING}
        >
          Open panel
        </motion.span>
      ) : null}
    </AnimatePresence>
  </motion.button>
</motion.div>
State change

Content swap

Switching tabs or filters? Clear the old view quickly, bring the new one in a touch slower, and move each only a few pixels.

Live component view
PurposeMakes clear the content changed while the frame around it did not.
AvoidFull page-style transitions on views people flip between all the time.
View recipe+
<AnimatePresence mode="wait" initial={false}>
  <motion.div
    key={tab}
    initial={{ opacity: 0, transform: "translateY(4px)" }}
    animate={{ opacity: 1, transform: "translateY(0px)" }}
    exit={{
      opacity: 0,
      transform: "translateY(-4px)",
      transition: { duration: 0.12, ease: EASE_OUT },
    }}
    transition={{ duration: 0.18, ease: EASE_OUT }}
  />
</AnimatePresence>

Accessibility

Building the reduced-motion version

Turning everything off is the wrong fix. Keep the cues that help people follow along and drop the movement that can make them feel unwell.

Fade and recolor instead of moving.

Opacity, color and instant state swaps stay. Parallax, big translates, repeated scaling and springy overshoot are removed.

const reduce = useReducedMotion();

const hidden = {
  opacity: 0,
  transform: reduce ? "none" : "translateY(8px)",
};

const visible = {
  opacity: 1,
  transform: "translateY(0px)",
};