Kosmesis
Components

Text Animation

A per-word/per-character/per-line staggered text reveal with 29 built-in effect presets. Purely presentational — no Morphos equivalent.

Installation

npx kosmesis add text-animation
pnpm dlx kosmesis add text-animation
yarn dlx kosmesis add text-animation
bunx kosmesis add text-animation

Copy and paste the following code into your project.

text-animation.tsx
import { StatefulComponent } from "@praxisjs/core";import { Component, OnCommand, Prop, State, type Command } from "@praxisjs/decorators";import { cn } from "@/lib/utils";const DIST_SM = 20;const DIST_LG = 42;const ROT_SM = 10;const ROT_LG = 25;const FLIP_DEG = 80;const FLIP_TILT_DEG = 55;const SKEW_DEG = 10;const BLUR_PX = 8;const PERSPECTIVE = 400;const EASE_BOUNCE = "cubic-bezier(0.34, 1.56, 0.64, 1)";const EASE_ELASTIC = "cubic-bezier(0.68, -0.55, 0.27, 1.55)";interface UnitStyle {  opacity?: number;  x?: number;  y?: number;  scale?: number;  rotate?: number;  rotateX?: number;  rotateY?: number;  skewY?: number;  blur?: number;}function toTransform(s: UnitStyle): string {  const parts: string[] = [];  if (s.rotateX !== undefined || s.rotateY !== undefined) parts.push(`perspective(${String(PERSPECTIVE)}px)`);  if (s.x !== undefined || s.y !== undefined) parts.push(`translate(${String(s.x ?? 0)}px, ${String(s.y ?? 0)}px)`);  if (s.scale !== undefined) parts.push(`scale(${String(s.scale)})`);  if (s.rotate !== undefined) parts.push(`rotate(${String(s.rotate)}deg)`);  if (s.rotateX !== undefined) parts.push(`rotateX(${String(s.rotateX)}deg)`);  if (s.rotateY !== undefined) parts.push(`rotateY(${String(s.rotateY)}deg)`);  if (s.skewY !== undefined) parts.push(`skewY(${String(s.skewY)}deg)`);  return parts.length > 0 ? parts.join(" ") : "none";}function frameDecls(s: UnitStyle): string {  const decls: string[] = [];  if (s.opacity !== undefined) decls.push(`opacity:${String(s.opacity)}`);  const t = toTransform(s);  if (t !== "none") decls.push(`transform:${t}`);  if (s.blur !== undefined) decls.push(`filter:blur(${String(s.blur)}px)`);  return decls.join(";");}interface TextEffectDef {  name: string;  css: string;  easing: string;  /** Overrides the component's `duration` prop — only `"wave"` (a continuous loop) needs this. */  duration?: number;  infinite?: boolean;}function entrance(effect: string, from: UnitStyle, to: UnitStyle, easing = "ease"): TextEffectDef {  const name = `kosmesis-text-${effect}`;  return { name, css: `@keyframes ${name} { from { ${frameDecls(from)}; } to { ${frameDecls(to)}; } }`, easing };}/** `"wave"` loops infinitely; every other effect is a one-shot entrance. */export type TextAnimationEffect =  | "fade"  | "fade-up"  | "fade-down"  | "fade-left"  | "fade-right"  | "slide-up"  | "slide-down"  | "slide-left"  | "slide-right"  | "zoom-in"  | "zoom-out"  | "flip-x"  | "flip-y"  | "flip-up"  | "flip-down"  | "rotate-in"  | "rotate-left"  | "rotate-right"  | "skew-up"  | "skew-down"  | "blur-in"  | "bounce-in"  | "bounce-up"  | "elastic-in"  | "pop"  | "roll-in-left"  | "roll-in-right"  | "drop-in"  | "wave";const TEXT_EFFECTS: Record<TextAnimationEffect, TextEffectDef> = {  fade: entrance("fade", { opacity: 0 }, { opacity: 1 }),  "fade-up": entrance("fade-up", { opacity: 0, y: DIST_SM }, { opacity: 1, y: 0 }),  "fade-down": entrance("fade-down", { opacity: 0, y: -DIST_SM }, { opacity: 1, y: 0 }),  "fade-left": entrance("fade-left", { opacity: 0, x: DIST_SM }, { opacity: 1, x: 0 }),  "fade-right": entrance("fade-right", { opacity: 0, x: -DIST_SM }, { opacity: 1, x: 0 }),  "slide-up": entrance("slide-up", { y: DIST_LG }, { y: 0 }),  "slide-down": entrance("slide-down", { y: -DIST_LG }, { y: 0 }),  "slide-left": entrance("slide-left", { x: DIST_LG }, { x: 0 }),  "slide-right": entrance("slide-right", { x: -DIST_LG }, { x: 0 }),  "zoom-in": entrance("zoom-in", { opacity: 0, scale: 0.5 }, { opacity: 1, scale: 1 }),  "zoom-out": entrance("zoom-out", { opacity: 0, scale: 1.5 }, { opacity: 1, scale: 1 }),  "flip-x": entrance("flip-x", { opacity: 0, rotateX: FLIP_DEG }, { opacity: 1, rotateX: 0 }),  "flip-y": entrance("flip-y", { opacity: 0, rotateY: FLIP_DEG }, { opacity: 1, rotateY: 0 }),  "flip-up": entrance("flip-up", { opacity: 0, rotateX: -FLIP_TILT_DEG, y: 10 }, { opacity: 1, rotateX: 0, y: 0 }),  "flip-down": entrance("flip-down", { opacity: 0, rotateX: FLIP_TILT_DEG, y: -10 }, { opacity: 1, rotateX: 0, y: 0 }),  "rotate-in": entrance("rotate-in", { opacity: 0, rotate: -ROT_SM }, { opacity: 1, rotate: 0 }),  "rotate-left": entrance("rotate-left", { opacity: 0, rotate: -ROT_LG, x: 10 }, { opacity: 1, rotate: 0, x: 0 }),  "rotate-right": entrance("rotate-right", { opacity: 0, rotate: ROT_LG, x: -10 }, { opacity: 1, rotate: 0, x: 0 }),  "skew-up": entrance("skew-up", { opacity: 0, skewY: -SKEW_DEG, y: 10 }, { opacity: 1, skewY: 0, y: 0 }),  "skew-down": entrance("skew-down", { opacity: 0, skewY: SKEW_DEG, y: -10 }, { opacity: 1, skewY: 0, y: 0 }),  "blur-in": entrance("blur-in", { opacity: 0, blur: BLUR_PX }, { opacity: 1, blur: 0 }),  "bounce-in": entrance("bounce-in", { opacity: 0, scale: 0.3 }, { opacity: 1, scale: 1 }, EASE_BOUNCE),  "bounce-up": entrance("bounce-up", { opacity: 0, y: DIST_LG, scale: 0.85 }, { opacity: 1, y: 0, scale: 1 }, EASE_BOUNCE),  "elastic-in": entrance("elastic-in", { opacity: 0, scale: 0.4 }, { opacity: 1, scale: 1 }, EASE_ELASTIC),  pop: entrance("pop", { opacity: 0, scale: 0.85 }, { opacity: 1, scale: 1 }, EASE_BOUNCE),  "roll-in-left": entrance("roll-in-left", { opacity: 0, x: -DIST_LG, rotate: -90 }, { opacity: 1, x: 0, rotate: 0 }),  "roll-in-right": entrance("roll-in-right", { opacity: 0, x: DIST_LG, rotate: 90 }, { opacity: 1, x: 0, rotate: 0 }),  "drop-in": entrance("drop-in", { opacity: 0, y: -DIST_LG * 1.4, scale: 0.9 }, { opacity: 1, y: 0, scale: 1 }, EASE_BOUNCE),  wave: {    name: "kosmesis-text-wave",    css: "@keyframes kosmesis-text-wave { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }",    easing: "ease-in-out",    duration: 1200,    infinite: true,  },};export interface TextAnimationProps {  text: string;  by?: "word" | "character" | "line";  effect?: TextAnimationEffect;  duration?: number;  stagger?: number;  /** Repeats the entrance forever, alternating in/out — "wave" already loops and ignores this. */  loop?: boolean;  /** A `@Command()` field from the parent — calling `trigger()` replays the animation on demand. */  trigger?: Command;  class?: string;  id?: string;}/** * Each unit gets a plain CSS `animation`, not a JS-driven transition — there's no single shared * trigger point to toggle. Instances sharing an `effect` embed byte-identical `<style>` content; * that duplication is intentional, not a bug to dedupe. Replaying (via `trigger`) works by * switching every unit's `animation-name` to `none` for one frame, then back — the standard way to * restart a CSS animation that's already finished. */@Component()export class TextAnimation extends StatefulComponent {  @Prop() text!: string;  @Prop() by: "word" | "character" | "line" = "word";  @Prop() effect: TextAnimationEffect = "fade-up";  @Prop() duration = 400;  @Prop() stagger = 40;  @Prop() loop = false;  @Prop() trigger?: Command;  @Prop() class?: string;  @Prop() id?: string;  @State() _replaying = false;  @OnCommand("trigger")  private _replay(): void {    this._replaying = true;    // Double rAF: a single one can get coalesced with this synchronous write into the same    // frame, so `animation-name: none` never actually paints and the animation never restarts.    requestAnimationFrame(() => {      requestAnimationFrame(() => {        this._replaying = false;      });    });  }  render() {    const { text, by, effect, duration, stagger, class: cls, id } = this;    const units = by === "word" ? text.split(" ") : by === "line" ? text.split("\n") : Array.from(text);    const def = TEXT_EFFECTS[effect];    return (      <span id={id} data-slot="text-animation" class={cn("inline", cls)}>        <style>{def.css}</style>        {units.map((unit, i) => (          <span            key={i}            class={cn("inline-block", by === "line" && "block")}            style={() => ({              marginRight: by === "word" && i < units.length - 1 ? "0.25em" : undefined,              animationName: this._replaying ? "none" : def.name,              animationDuration: `${String(def.duration ?? duration)}ms`,              animationTimingFunction: def.easing,              animationDelay: `${String(i * stagger)}ms`,              animationFillMode: def.infinite ? undefined : "backwards",              animationIterationCount: def.infinite || this.loop ? "infinite" : undefined,              animationDirection: this.loop && !def.infinite ? "alternate" : undefined,            })}          >            {by === "word" && i < units.length - 1 ? `${unit} ` : unit}          </span>        ))}      </span>    );  }}

Install the following dependencies:

npm install @praxisjs/css
pnpm add @praxisjs/css
yarn add @praxisjs/css
bun add @praxisjs/css

Copy and paste the following code into your project.

text-animation.tsx
import { StatefulComponent } from "@praxisjs/core";import { cx, keyframes, Stylesheet, Styled } from "@praxisjs/css";import { Component, OnCommand, Prop, State, type Command } from "@praxisjs/decorators";const DIST_SM = 20;const DIST_LG = 42;const ROT_SM = 10;const ROT_LG = 25;const FLIP_DEG = 80;const FLIP_TILT_DEG = 55;const SKEW_DEG = 10;const BLUR_PX = 8;const PERSPECTIVE = 400;const EASE_BOUNCE = "cubic-bezier(0.34, 1.56, 0.64, 1)";const EASE_ELASTIC = "cubic-bezier(0.68, -0.55, 0.27, 1.55)";interface UnitStyle {  opacity?: number;  x?: number;  y?: number;  scale?: number;  rotate?: number;  rotateX?: number;  rotateY?: number;  skewY?: number;  blur?: number;}function toTransform(s: UnitStyle): string {  const parts: string[] = [];  if (s.rotateX !== undefined || s.rotateY !== undefined) parts.push(`perspective(${String(PERSPECTIVE)}px)`);  if (s.x !== undefined || s.y !== undefined) parts.push(`translate(${String(s.x ?? 0)}px, ${String(s.y ?? 0)}px)`);  if (s.scale !== undefined) parts.push(`scale(${String(s.scale)})`);  if (s.rotate !== undefined) parts.push(`rotate(${String(s.rotate)}deg)`);  if (s.rotateX !== undefined) parts.push(`rotateX(${String(s.rotateX)}deg)`);  if (s.rotateY !== undefined) parts.push(`rotateY(${String(s.rotateY)}deg)`);  if (s.skewY !== undefined) parts.push(`skewY(${String(s.skewY)}deg)`);  return parts.length > 0 ? parts.join(" ") : "none";}function frameStyle(s: UnitStyle): Record<string, string> {  const style: Record<string, string> = {};  if (s.opacity !== undefined) style.opacity = String(s.opacity);  const t = toTransform(s);  if (t !== "none") style.transform = t;  if (s.blur !== undefined) style.filter = `blur(${String(s.blur)}px)`;  return style;}interface TextEffectDef {  name: string;  easing: string;  /** Overrides the component's `duration` prop — only `"wave"` (a continuous loop) needs this. */  duration?: number;  infinite?: boolean;}function entrance(effect: string, from: UnitStyle, to: UnitStyle, easing = "ease"): TextEffectDef {  const name = keyframes(`kosmesis-text-${effect}`, { from: frameStyle(from), to: frameStyle(to) });  return { name, easing };}/** `"wave"` loops infinitely; every other effect is a one-shot entrance. */export type TextAnimationEffect =  | "fade"  | "fade-up"  | "fade-down"  | "fade-left"  | "fade-right"  | "slide-up"  | "slide-down"  | "slide-left"  | "slide-right"  | "zoom-in"  | "zoom-out"  | "flip-x"  | "flip-y"  | "flip-up"  | "flip-down"  | "rotate-in"  | "rotate-left"  | "rotate-right"  | "skew-up"  | "skew-down"  | "blur-in"  | "bounce-in"  | "bounce-up"  | "elastic-in"  | "pop"  | "roll-in-left"  | "roll-in-right"  | "drop-in"  | "wave";const TEXT_EFFECTS: Record<TextAnimationEffect, TextEffectDef> = {  fade: entrance("fade", { opacity: 0 }, { opacity: 1 }),  "fade-up": entrance("fade-up", { opacity: 0, y: DIST_SM }, { opacity: 1, y: 0 }),  "fade-down": entrance("fade-down", { opacity: 0, y: -DIST_SM }, { opacity: 1, y: 0 }),  "fade-left": entrance("fade-left", { opacity: 0, x: DIST_SM }, { opacity: 1, x: 0 }),  "fade-right": entrance("fade-right", { opacity: 0, x: -DIST_SM }, { opacity: 1, x: 0 }),  "slide-up": entrance("slide-up", { y: DIST_LG }, { y: 0 }),  "slide-down": entrance("slide-down", { y: -DIST_LG }, { y: 0 }),  "slide-left": entrance("slide-left", { x: DIST_LG }, { x: 0 }),  "slide-right": entrance("slide-right", { x: -DIST_LG }, { x: 0 }),  "zoom-in": entrance("zoom-in", { opacity: 0, scale: 0.5 }, { opacity: 1, scale: 1 }),  "zoom-out": entrance("zoom-out", { opacity: 0, scale: 1.5 }, { opacity: 1, scale: 1 }),  "flip-x": entrance("flip-x", { opacity: 0, rotateX: FLIP_DEG }, { opacity: 1, rotateX: 0 }),  "flip-y": entrance("flip-y", { opacity: 0, rotateY: FLIP_DEG }, { opacity: 1, rotateY: 0 }),  "flip-up": entrance("flip-up", { opacity: 0, rotateX: -FLIP_TILT_DEG, y: 10 }, { opacity: 1, rotateX: 0, y: 0 }),  "flip-down": entrance("flip-down", { opacity: 0, rotateX: FLIP_TILT_DEG, y: -10 }, { opacity: 1, rotateX: 0, y: 0 }),  "rotate-in": entrance("rotate-in", { opacity: 0, rotate: -ROT_SM }, { opacity: 1, rotate: 0 }),  "rotate-left": entrance("rotate-left", { opacity: 0, rotate: -ROT_LG, x: 10 }, { opacity: 1, rotate: 0, x: 0 }),  "rotate-right": entrance("rotate-right", { opacity: 0, rotate: ROT_LG, x: -10 }, { opacity: 1, rotate: 0, x: 0 }),  "skew-up": entrance("skew-up", { opacity: 0, skewY: -SKEW_DEG, y: 10 }, { opacity: 1, skewY: 0, y: 0 }),  "skew-down": entrance("skew-down", { opacity: 0, skewY: SKEW_DEG, y: -10 }, { opacity: 1, skewY: 0, y: 0 }),  "blur-in": entrance("blur-in", { opacity: 0, blur: BLUR_PX }, { opacity: 1, blur: 0 }),  "bounce-in": entrance("bounce-in", { opacity: 0, scale: 0.3 }, { opacity: 1, scale: 1 }, EASE_BOUNCE),  "bounce-up": entrance("bounce-up", { opacity: 0, y: DIST_LG, scale: 0.85 }, { opacity: 1, y: 0, scale: 1 }, EASE_BOUNCE),  "elastic-in": entrance("elastic-in", { opacity: 0, scale: 0.4 }, { opacity: 1, scale: 1 }, EASE_ELASTIC),  pop: entrance("pop", { opacity: 0, scale: 0.85 }, { opacity: 1, scale: 1 }, EASE_BOUNCE),  "roll-in-left": entrance("roll-in-left", { opacity: 0, x: -DIST_LG, rotate: -90 }, { opacity: 1, x: 0, rotate: 0 }),  "roll-in-right": entrance("roll-in-right", { opacity: 0, x: DIST_LG, rotate: 90 }, { opacity: 1, x: 0, rotate: 0 }),  "drop-in": entrance("drop-in", { opacity: 0, y: -DIST_LG * 1.4, scale: 0.9 }, { opacity: 1, y: 0, scale: 1 }, EASE_BOUNCE),  wave: {    name: keyframes("kosmesis-text-wave", { "0%, 100%": { transform: "translateY(0)" }, "50%": { transform: "translateY(-6px)" } }),    easing: "ease-in-out",    duration: 1200,    infinite: true,  },};class TextAnimationStyles extends Stylesheet {  $root = this.css({ display: "inline" });  $unit = this.css({ display: "inline-block" });  $unitLine = this.css({ display: "block" });}export interface TextAnimationProps {  text: string;  by?: "word" | "character" | "line";  effect?: TextAnimationEffect;  duration?: number;  stagger?: number;  /** Repeats the entrance forever, alternating in/out — "wave" already loops and ignores this. */  loop?: boolean;  /** A `@Command()` field from the parent — calling `trigger()` replays the animation on demand. */  trigger?: Command;  class?: string;  id?: string;}/** * Each unit gets a plain CSS `animation`, not a JS-driven transition — no single shared trigger * point across units. Replaying (via `trigger`) works by switching every unit's `animation-name` * to `none` for one frame, then back — the standard way to restart a CSS animation that's already * finished. */@Component()export class TextAnimation extends StatefulComponent {  @Styled(TextAnimationStyles) $s!: TextAnimationStyles;  @Prop() text!: string;  @Prop() by: "word" | "character" | "line" = "word";  @Prop() effect: TextAnimationEffect = "fade-up";  @Prop() duration = 400;  @Prop() stagger = 40;  @Prop() loop = false;  @Prop() trigger?: Command;  @Prop() class?: string;  @Prop() id?: string;  @State() _replaying = false;  @OnCommand("trigger")  private _replay(): void {    this._replaying = true;    // Double rAF: a single one can get coalesced with this synchronous write into the same    // frame, so `animation-name: none` never actually paints and the animation never restarts.    requestAnimationFrame(() => {      requestAnimationFrame(() => {        this._replaying = false;      });    });  }  render() {    const { text, by, effect, duration, stagger, class: cls, id } = this;    const units = by === "word" ? text.split(" ") : by === "line" ? text.split("\n") : Array.from(text);    const def = TEXT_EFFECTS[effect];    return (      <span id={id} data-slot="text-animation" class={cx(this.$s.$root, cls)}>        {units.map((unit, i) => (          <span            key={i}            class={cx(this.$s.$unit, by === "line" && this.$s.$unitLine)}            style={() => ({              marginRight: by === "word" && i < units.length - 1 ? "0.25em" : undefined,              animationName: this._replaying ? "none" : def.name,              animationDuration: `${String(def.duration ?? duration)}ms`,              animationTimingFunction: def.easing,              animationDelay: `${String(i * stagger)}ms`,              animationFillMode: def.infinite ? undefined : "backwards",              animationIterationCount: def.infinite || this.loop ? "infinite" : undefined,              animationDirection: this.loop && !def.infinite ? "alternate" : undefined,            })}          >            {by === "word" && i < units.length - 1 ? `${unit} ` : unit}          </span>        ))}      </span>    );  }}

Examples

Every preset gets its own Storybook story, each with a Loop switch and a Replay button — built from this registry's own Switch and Button — so you can toggle looping on/off and re-trigger the reveal on demand without touching code.

Open "Open in Storybook" above and browse the Tailwind/Text Animation (or PraxisCSS/Text Animation) sidebar entry for the same demo across all 29 presets (including wave, whose switch has no effect since it already loops unconditionally).

About

Purely presentational — no Morphos equivalent. Each unit (word, character, or line) gets a plain CSS animation, not a JS-driven transition, so every named effect's @keyframes is generated once (from the same from/to model Motion uses, kept as a small local, duplicated copy — every registry component is standalone copy-paste source) and animation-delay staggers each unit's start. The Tailwind flavor embeds the current effect's @keyframes via one <style> tag; the @praxisjs/css flavor registers all 29 via keyframes() at module load. Either way, animation-fill-mode: backwards holds each unit at its from state until its delay elapses, so units don't flash visible before their turn.

29 built-in effect presets — the same 28 families as Motion's MotionEffect (see that page for the direction-suffix convention), plus "wave": a continuous, infinitely-looping bob with no Motion equivalent, since it only makes sense applied per-unit with a phase offset from the stagger.

Usage

import { TextAnimation } from "@/components/ui/text-animation";

<TextAnimation text="Build beautiful interfaces faster" by="word" effect="fade-up" />
<TextAnimation text="BOUNCE" by="character" effect="bounce-in" stagger={60} />
<TextAnimation text="Line one\nLine two" by="line" effect="slide-left" />

Triggering it imperatively

Pass a trigger prop — a PraxisJS Command — to replay the whole reveal on demand, the same @Command()/@OnCommand() pattern Motion uses. Internally, every unit's animation-name is switched to none for one frame and back — the standard way to restart a finished CSS animation. loop is reactive too, so a plain @State() boolean bound to a Switch can turn it on and off at runtime — see the Storybook examples above for both wired up together.

import { StatefulComponent } from "@praxisjs/core";
import { Command, Component } from "@praxisjs/decorators";
import { TextAnimation } from "@/components/ui/text-animation";

@Component()
class Demo extends StatefulComponent {
  @Command() replay!: Command;

  render() {
    return (
      <>
        <TextAnimation trigger={this.replay} text="Read me again" by="word" effect="fade-up" />
        <button onClick={() => this.replay.trigger()}>Replay</button>
      </>
    );
  }
}

Props

PropTypeDefault
textstring
by"word" | "character" | "line""word"
effectTextAnimationEffect (29 presets — same families as MotionEffect, plus "wave")"fade-up"
durationnumber (ms; ignored by "wave", which always runs at 1200ms)400
staggernumber (ms between each unit's animation start)40
loopboolean (repeats forever, alternating in/out; "wave" already loops and ignores this; reactive — can be toggled after mount)false
triggerCommand (a @Command() field — calling .trigger() replays the animation)
classstring

On this page