Skip to content

Commit 66a5030

Browse files
committed
feat: migrate root char-animation into animations
1 parent 6ff2099 commit 66a5030

7 files changed

Lines changed: 168 additions & 64 deletions

File tree

.claude/skills/rustmotion/SKILL.md

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,8 @@ All components are discriminated by `"type"`. Rendered in array order (first = b
412412

413413
| Effect name | Fields | Description |
414414
| --- | --- | --- |
415-
| *preset name* | `delay`, `duration`, `loop` | Any of the 39 presets (e.g. `fade_in_up`, `scale_in`) |
415+
| *preset name* | `delay`, `duration`, `loop`, `overshoot` | Any of the 39 presets (e.g. `fade_in_up`, `scale_in`) |
416+
| *char preset* | `delay`, `duration`, `stagger`, `granularity`, `easing`, `overshoot` | Per-char/word animation: `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up` |
416417
| `glow` | `color`, `radius`, `intensity` | Luminous halo effect |
417418
| `wiggle` | `property`, `amplitude`, `frequency`, `mode`, `seed`, ... | Procedural noise animation |
418419
| `orbit` | `radius_x`, `radius_y`, `speed`, `depth`, `tilt`, ... | Elliptical/circular orbital motion with pseudo-3D depth |
@@ -426,6 +427,7 @@ All components are discriminated by `"type"`. Rendered in array order (first = b
426427
| `delay` | f64 | `0` | Delay before animation starts (seconds) |
427428
| `duration` | f64 | `0.8` | Animation duration (seconds) |
428429
| `loop` | bool | `false` | Loop the animation continuously |
430+
| `overshoot` | f64 | `0.08` | Overshoot/anticipation intensity for `scale_in`/`scale_out` (0.0 = none) |
429431

430432
**Glow fields:**
431433

@@ -456,7 +458,7 @@ All components are discriminated by `"type"`. Rendered in array order (first = b
456458
}
457459
```
458460

459-
**Root fields:** `content` (required), `max_width`, `char-animation`
461+
**Root fields:** `content` (required), `max_width`
460462

461463
| Style field | Type | Default |
462464
| ----------------- | -------- | ---------- |
@@ -472,48 +474,42 @@ All components are discriminated by `"type"`. Rendered in array order (first = b
472474
| `stroke` | object | `null``{ "color": "#000", "width": 2 }` |
473475
| `text-background` | object | `null``{ "color": "#000", "padding": 4, "corner_radius": 4 }` |
474476

475-
**Per-character / per-word animation (`char-animation` field):**
477+
**Per-character / per-word animation (char animation presets):**
476478

477-
Animates each character or word independently with staggered timing.
479+
Animates each character or word independently with staggered timing. Use `char_*` animation presets inside `style.animation`:
478480

479481
```json
480482
{
481483
"type": "text",
482484
"content": "Hello World",
483-
"char-animation": {
484-
"preset": "scale_in",
485-
"stagger": 0.03,
486-
"duration": 0.4,
487-
"delay": 0.2,
488-
"easing": "ease_out",
489-
"granularity": "char"
490-
},
491-
"style": { "font-size": 64, "color": "#FFFFFF" }
485+
"style": {
486+
"font-size": 64, "color": "#FFFFFF",
487+
"animation": [{ "name": "char_scale_in", "stagger": 0.03, "duration": 0.4, "delay": 0.2, "easing": "ease_out" }]
488+
}
492489
}
493490
```
494491

495-
| Field | Type | Default | Description |
496-
| ------------- | ------ | ------------ | ------------------------------------------------ |
497-
| `preset` | enum | `"scale_in"` | `"scale_in"`, `"fade_in"`, `"wave"`, `"bounce"`, `"rotate_in"`, `"slide_up"` |
498-
| `stagger` | f32 | `0.03` | Delay between each unit (seconds) |
499-
| `duration` | f32 | `0.4` | Duration of each unit's animation (seconds) |
500-
| `delay` | f32 | `0.0` | Initial delay before the first unit starts |
501-
| `easing` | string | `"linear"` | Easing function (same as keyframe easings) |
502-
| `granularity` | enum | `"char"` | `"char"` (per-character) or `"word"` (per-word) |
492+
**Char animation presets:** `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up`
493+
494+
| Field | Type | Default | Description |
495+
| ------------- | ------ | ---------- | ------------------------------------------------ |
496+
| `stagger` | f64 | `0.03` | Delay between each unit (seconds) |
497+
| `duration` | f64 | `0.4` | Duration of each unit's animation (seconds) |
498+
| `delay` | f64 | `0.0` | Initial delay before the first unit starts |
499+
| `easing` | string | `"linear"` | Easing function (same as keyframe easings) |
500+
| `granularity` | enum | `"char"` | `"char"` (per-character) or `"word"` (per-word) |
501+
| `overshoot` | f64 | `0.08` | Overshoot intensity for `char_scale_in`/`char_bounce` (0.0 = none) |
503502

504503
**Per-word mode** (`"granularity": "word"`) splits text by whitespace and animates each word as a unit. Ideal for headline reveals with larger stagger values (0.1-0.3s):
505504

506505
```json
507506
{
508507
"type": "text",
509508
"content": "One platform to rule them all",
510-
"char-animation": {
511-
"preset": "fade_in",
512-
"stagger": 0.15,
513-
"duration": 0.5,
514-
"granularity": "word"
515-
},
516-
"style": { "font-size": 56, "color": "#FFFFFF", "font-weight": "bold" }
509+
"style": {
510+
"font-size": 56, "color": "#FFFFFF", "font-weight": "bold",
511+
"animation": [{ "name": "char_fade_in", "stagger": 0.15, "duration": 0.5, "granularity": "word" }]
512+
}
517513
}
518514
```
519515

@@ -1479,6 +1475,7 @@ See Rule 13 for usage guidance.
14791475
| 3D | `flip_in_x`, `flip_in_y`, `flip_out_x`, `flip_out_y` (3D card flip), `tilt_in` (3D tilt with rotate_x + rotate_y) |
14801476
| Stroke | `draw_in` (animate `draw_progress` 0→1 for arrows/connectors/lines), `stroke_reveal` (draw_in + fade-in opacity over first 20%) |
14811477
| Special | `typewriter`, `wipe_left`, `wipe_right` |
1478+
| Char (text only) | `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up` (per-char/word animation, extra fields: `stagger`, `granularity`, `overshoot`) |
14821479

14831480
#### Wiggle (Procedural Noise)
14841481

.claude/skills/rustmotion/rules/prefer-presets.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Presets are simpler, less error-prone, and produce consistent motion design. Onl
2222
}
2323
```
2424

25-
## 39 Available Presets
25+
## 45 Available Presets
2626

2727
| Category | Presets |
2828
| ---------- | ------ |
@@ -32,3 +32,6 @@ Presets are simpler, less error-prone, and produce consistent motion design. Onl
3232
| 3D | `flip_in_x`, `flip_in_y`, `flip_out_x`, `flip_out_y`, `tilt_in` |
3333
| Stroke | `draw_in` (animate stroke drawing), `stroke_reveal` (draw_in + fade-in) |
3434
| Special | `typewriter`, `wipe_left`, `wipe_right` |
35+
| Char (text only) | `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up` |
36+
37+
`scale_in` and `scale_out` support `overshoot` (default 0.08 = 8%). Char presets support `stagger`, `granularity`, `easing`, and `overshoot`.

README.md

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1266,7 +1266,8 @@ All animation effects are defined inside `style.animation` as a **typed array**,
12661266

12671267
| Effect name | Fields | Description |
12681268
|---|---|---|
1269-
| *preset name* | `delay`, `duration`, `loop` | Any of the 39 presets (e.g. `fade_in_up`, `scale_in`) |
1269+
| *preset name* | `delay`, `duration`, `loop`, `overshoot` | Any of the 39 presets (e.g. `fade_in_up`, `scale_in`) |
1270+
| *char preset* | `delay`, `duration`, `stagger`, `granularity`, `easing`, `overshoot` | Per-char/word text animation: `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up` |
12701271
| `glow` | `color`, `radius`, `intensity` | Luminous halo effect |
12711272
| `wiggle` | `property`, `amplitude`, `frequency`, `mode`, `seed`, ... | Procedural noise animation |
12721273
| `orbit` | `radius_x`, `radius_y`, `speed`, `depth`, `tilt`, ... | Elliptical orbital motion with pseudo-3D |
@@ -1290,6 +1291,7 @@ Components also support a `timeline` field (array of `{ "at": f64, "animation":
12901291
| `delay` | `f64` | `0.0` | Delay before animation starts (seconds) |
12911292
| `duration` | `f64` | `0.8` | Animation duration (seconds) |
12921293
| `loop` | `bool` | `false` | Loop the animation continuously |
1294+
| `overshoot` | `f64` | `0.08` | Overshoot/anticipation intensity for `scale_in`/`scale_out` (0.0 = none) |
12931295

12941296
#### Entrance Presets
12951297

@@ -1304,7 +1306,7 @@ Components also support a `timeline` field (array of `{ "at": f64, "animation":
13041306
| `slide_in_right` | Slide in from far right |
13051307
| `slide_in_up` | Slide in from below |
13061308
| `slide_in_down` | Slide in from above |
1307-
| `scale_in` | Scale up from 0 with spring bounce |
1309+
| `scale_in` | Scale up from 0 with overshoot (configurable via `overshoot`, default 8%) |
13081310
| `bounce_in` | Bouncy scale from small to normal |
13091311
| `blur_in` | Fade in from blurred |
13101312
| `rotate_in` | Rotate + scale from half size |
@@ -1321,7 +1323,7 @@ Components also support a `timeline` field (array of `{ "at": f64, "animation":
13211323
| `slide_out_right` | Slide out to the right |
13221324
| `slide_out_up` | Slide out upward |
13231325
| `slide_out_down` | Slide out downward |
1324-
| `scale_out` | Scale down to 0 |
1326+
| `scale_out` | Scale down to 0 with anticipation (configurable via `overshoot`, default 8%) |
13251327
| `bounce_out` | Bouncy scale to small |
13261328
| `blur_out` | Fade out with blur |
13271329
| `rotate_out` | Rotate + scale to half size |
@@ -1473,45 +1475,40 @@ Creates continuous circular or elliptical orbital motion with pseudo-3D depth. A
14731475

14741476
### Per-Character / Per-Word Text Animation
14751477

1476-
Animate each character or word independently with staggered timing. Set `char-animation` on `text` components.
1478+
Animate each character or word independently with staggered timing. Use `char_*` animation presets inside `style.animation` on `text` components.
1479+
1480+
**Char animation presets:** `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up`
14771481

14781482
```json
14791483
{
14801484
"type": "text",
14811485
"content": "Hello World",
1482-
"char-animation": {
1483-
"preset": "scale_in",
1484-
"stagger": 0.03,
1485-
"duration": 0.4,
1486-
"delay": 0.2,
1487-
"granularity": "char"
1488-
},
1489-
"style": { "font-size": 64, "color": "#FFFFFF" }
1486+
"style": {
1487+
"font-size": 64, "color": "#FFFFFF",
1488+
"animation": [{ "name": "char_scale_in", "stagger": 0.03, "duration": 0.4, "delay": 0.2 }]
1489+
}
14901490
}
14911491
```
14921492

14931493
| Field | Type | Default | Description |
14941494
|---|---|---|---|
1495-
| `preset` | `string` | `"scale_in"` | `"scale_in"`, `"fade_in"`, `"wave"`, `"bounce"`, `"rotate_in"`, `"slide_up"` |
1496-
| `stagger` | `f32` | `0.03` | Delay between each unit (seconds) |
1497-
| `duration` | `f32` | `0.4` | Each unit's animation duration |
1498-
| `delay` | `f32` | `0.0` | Initial delay before first unit |
1495+
| `stagger` | `f64` | `0.03` | Delay between each unit (seconds) |
1496+
| `duration` | `f64` | `0.4` | Each unit's animation duration |
1497+
| `delay` | `f64` | `0.0` | Initial delay before first unit |
14991498
| `easing` | `string` | `"linear"` | Easing function |
15001499
| `granularity` | `string` | `"char"` | `"char"` (per-character) or `"word"` (per-word) |
1500+
| `overshoot` | `f64` | `0.08` | Overshoot intensity for `char_scale_in`/`char_bounce` (0.0 = none) |
15011501

15021502
**Per-word mode** (`"granularity": "word"`) splits text by whitespace and animates each word as a unit. Use larger stagger values (0.1–0.3s) for word reveals:
15031503

15041504
```json
15051505
{
15061506
"type": "text",
15071507
"content": "One platform to rule them all",
1508-
"char-animation": {
1509-
"preset": "fade_in",
1510-
"stagger": 0.15,
1511-
"duration": 0.5,
1512-
"granularity": "word"
1513-
},
1514-
"style": { "font-size": 56, "color": "#FFFFFF", "font-weight": "bold" }
1508+
"style": {
1509+
"font-size": 56, "color": "#FFFFFF", "font-weight": "bold",
1510+
"animation": [{ "name": "char_fade_in", "stagger": 0.15, "duration": 0.5, "granularity": "word" }]
1511+
}
15151512
}
15161513
```
15171514

src/components/text.rs

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,6 @@ pub struct Text {
2626
pub content: String,
2727
#[serde(default)]
2828
pub max_width: Option<f32>,
29-
/// Per-character animation (each letter animates independently).
30-
#[serde(default, rename = "char-animation")]
31-
pub char_animation: Option<CharAnimation>,
3229
#[serde(flatten)]
3330
pub timing: TimingConfig,
3431
#[serde(default)]
@@ -57,13 +54,25 @@ fn apply_text_anim_preset(
5754
time: f64,
5855
unit_idx: usize,
5956
font_size: f32,
57+
overshoot: f32,
6058
) {
6159
let center_x = cursor_x + unit_width / 2.0;
6260
let center_y = line_y;
6361

6462
match preset {
6563
CharAnimPreset::ScaleIn => {
66-
let scale = t;
64+
// 0→(1+overshoot) at 70%, then settle to 1.0
65+
let scale = if overshoot > 0.001 {
66+
if t < 0.7 {
67+
let p = t / 0.7;
68+
p * (1.0 + overshoot)
69+
} else {
70+
let p = (t - 0.7) / 0.3;
71+
(1.0 + overshoot) - overshoot * p
72+
}
73+
} else {
74+
t
75+
};
6776
if scale < 0.001 {
6877
return;
6978
}
@@ -84,10 +93,11 @@ fn apply_text_anim_preset(
8493
draw_text_with_fallback(canvas, text, font, emoji_font, 0.0, cursor_x, line_y + wave_offset, &p);
8594
}
8695
CharAnimPreset::Bounce => {
96+
let peak = 1.0 + overshoot.max(0.3); // bounce always overshoots, min 0.3
8797
let scale = if t < 0.5 {
88-
t * 2.0 * 1.3
98+
t * 2.0 * peak
8999
} else {
90-
1.3 - 0.3 * ((t - 0.5) * 2.0)
100+
peak - (peak - 1.0) * ((t - 0.5) * 2.0)
91101
};
92102
let scale = scale.max(0.001);
93103
canvas.translate((center_x, center_y));
@@ -128,6 +138,7 @@ fn render_char_animation(
128138
lines: &[String],
129139
char_anim: &CharAnimation,
130140
time: f64,
141+
overshoot: f32,
131142
) {
132143
let is_word_mode = matches!(char_anim.granularity, TextAnimGranularity::Word);
133144
let mut global_unit_idx = 0usize;
@@ -200,6 +211,7 @@ fn render_char_animation(
200211
canvas, &word, font, emoji_font, paint,
201212
cursor_x, line_y, word_width,
202213
&char_anim.preset, t, time, global_unit_idx, font.size(),
214+
overshoot,
203215
);
204216
canvas.restore();
205217

@@ -230,6 +242,7 @@ fn render_char_animation(
230242
canvas, &ch_str, font, emoji_font, paint,
231243
cursor_x, line_y, ch_width,
232244
&char_anim.preset, t, time, global_unit_idx, font.size(),
245+
overshoot,
233246
);
234247
canvas.restore();
235248

@@ -347,12 +360,20 @@ impl Widget for Text {
347360
max_w
348361
};
349362

350-
// Per-character animation mode
351-
if let Some(ref char_anim) = self.char_animation {
363+
// Per-character animation mode (via style.animation char_* presets)
364+
if let Some(ref resolved) = props.char_animation {
365+
let char_anim = CharAnimation {
366+
preset: resolved.preset.clone(),
367+
granularity: resolved.granularity.clone(),
368+
stagger: resolved.stagger,
369+
duration: resolved.duration,
370+
easing: resolved.easing.clone(),
371+
delay: resolved.delay,
372+
};
352373
render_char_animation(
353374
canvas, &content, &font, &emoji_font, &paint,
354375
letter_spacing, align, align_width, line_height_val, baseline_offset,
355-
&lines, char_anim, ctx.time,
376+
&lines, &char_anim, ctx.time, resolved.overshoot,
356377
);
357378
return Ok(());
358379
}

0 commit comments

Comments
 (0)