use std::sync::OnceLock;
use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{RenderCtx, chars};
use crate::motion::{clamp01, mix_hex, motion_scale, random};
pub const ANTICIPATE_FRAMES: usize = 4;
pub const INFLATE_FRAMES: usize = 14;
pub const HOLD_FRAMES: usize = 20;
pub const DEFLATE_FRAMES: usize = 8;
pub const SQUASH: f32 = 0.08;
pub const WOBBLE_DEG: f32 = 4.0;
pub const BOUNCE_EM: f32 = 0.05;
pub const GOOEY_BLUR_CAP: f32 = 0.04;
pub const SPEED_NORM: f32 = 0.3;
pub const GOOEY_MATRIX: &str = "1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9";
pub fn inflate_kick(u: f32) -> f32 {
(-2.0 * u).exp() * (2.0 * std::f32::consts::PI * u).sin()
}
pub fn inflate_kick_peak() -> f32 {
static PEAK: OnceLock<f32> = OnceLock::new();
*PEAK.get_or_init(|| {
let mut peak = 0.0_f32;
for i in 1..=1000 {
peak = peak.max(inflate_kick(i as f32 / 1000.0));
}
peak
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LetterScale {
pub scale: f32,
pub scale_x: f32,
pub scale_y: f32,
}
pub fn letter_scale(frame: f32, start: f32, inflate_scale: f32) -> LetterScale {
let anticipate = ANTICIPATE_FRAMES as f32;
let inflate = INFLATE_FRAMES as f32;
let t_a = clamp01((frame - start) / anticipate);
let squash = (std::f32::consts::PI * t_a).sin();
let t_i = clamp01((frame - start - anticipate) / inflate);
let peak = inflate_kick_peak();
let kick = if peak > 0.0 {
inflate_kick(t_i) / peak
} else {
0.0
};
let scale = 1.0 + (inflate_scale - 1.0) * kick;
LetterScale {
scale,
scale_x: scale * (1.0 + SQUASH * squash),
scale_y: scale * (1.0 - SQUASH * squash),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LetterState {
pub ch: char,
pub scale_x: f32,
pub scale_y: f32,
pub rotate_deg: f32,
pub translate_y: f32,
pub fill: String,
pub opacity: f32,
}
pub fn letter_state(
frame: f32,
index: usize,
count: usize,
ch: char,
config: &InflatingTextConfig,
) -> Option<LetterState> {
if ch == ' ' {
return None;
}
let move_scale = motion_scale(config.reduced_motion);
let start = config.delay as f32 + index as f32 * config.stagger as f32;
let pose = letter_scale(frame, start, config.inflate_scale);
let deflate_start = config.delay as f32
+ count.saturating_sub(1) as f32 * config.stagger as f32
+ ANTICIPATE_FRAMES as f32
+ INFLATE_FRAMES as f32
+ config.hold as f32;
let t_d = clamp01(
(frame - deflate_start - index as f32 * config.deflate_stagger as f32)
/ DEFLATE_FRAMES as f32,
);
let opacity = 1.0 - t_d;
let deflate_mul = 1.0 - 0.15 * t_d;
let t_i = clamp01((frame - start - ANTICIPATE_FRAMES as f32) / INFLATE_FRAMES as f32);
let dir = if random(&format!("inflate-{index}")) < 0.5 {
-1.0
} else {
1.0
};
let rotate = dir * WOBBLE_DEG * (std::f32::consts::PI * t_i).sin() * move_scale;
let span = (config.inflate_scale - 1.0).max(1e-6);
let bounce = (pose.scale - 1.0) / span * BOUNCE_EM * config.font_size * move_scale;
let intensity = clamp01((pose.scale - 1.0).abs() / span);
let fill = mix_hex(&config.color, &config.accent_color, intensity);
Some(LetterState {
ch,
scale_x: 1.0 + (pose.scale_x * deflate_mul - 1.0) * move_scale,
scale_y: 1.0 + (pose.scale_y * deflate_mul - 1.0) * move_scale,
rotate_deg: rotate,
translate_y: -bounce,
fill,
opacity,
})
}
pub fn get_inflating_text_duration(
text: &str,
stagger: usize,
delay: usize,
hold: usize,
deflate_stagger: usize,
) -> usize {
let count = chars(text).len();
if count == 0 {
return 1;
}
let last_start = delay + (count - 1) * stagger;
let total = last_start
+ ANTICIPATE_FRAMES
+ INFLATE_FRAMES
+ hold
+ (count - 1) * deflate_stagger
+ DEFLATE_FRAMES;
total.max(1)
}
#[derive(Debug, Clone, PartialEq)]
pub struct InflatingTextConfig {
pub text: String,
pub font_size: f32,
pub font_weight: u16,
pub inflate_scale: f32,
pub stagger: usize,
pub delay: usize,
pub color: String,
pub accent_color: String,
pub tracking_em: f32,
pub hold: usize,
pub deflate_stagger: usize,
pub reduced_motion: bool,
}
impl Default for InflatingTextConfig {
fn default() -> Self {
Self {
text: "Make It Pop".to_owned(),
font_size: 110.0,
font_weight: 800,
inflate_scale: 1.22,
stagger: 2,
delay: 0,
color: "#f5f5f7".to_owned(),
accent_color: "#ffd166".to_owned(),
tracking_em: -0.02,
hold: HOLD_FRAMES,
deflate_stagger: 2,
reduced_motion: false,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct InflatingText {
pub config: InflatingTextConfig,
}
impl InflatingText {
pub fn new(config: InflatingTextConfig) -> Self {
Self { config }
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "inflating-text",
name: "Inflating Text",
summary: "Letters squash, inflate past rest size, wobble and tint, then deflate out.",
feature: "inflating-text",
}
}
}
fn em_center_to_baseline(ctx: &RenderCtx<'_>, family: &str, size: f32) -> f32 {
match ctx.meter(family) {
Some(meter) => (meter.ascent(size) - meter.descent(size)) / 2.0,
None => size * 0.25,
}
}
impl Component for InflatingText {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
get_inflating_text_duration(
&self.config.text,
self.config.stagger,
self.config.delay,
self.config.hold,
self.config.deflate_stagger,
)
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let family = ctx.sans();
let size = config.font_size;
let mid = em_center_to_baseline(ctx, &family, size);
let baseline = ctx.height() / 2.0 + mid;
let frame = ctx.frame();
let characters = chars(&config.text);
let widths: Vec<f32> = characters
.iter()
.map(|ch| ctx.text_width(&family, size, &ch.to_string()))
.collect();
let tracking_px = config.tracking_em * size;
let total =
widths.iter().sum::<f32>() + tracking_px * characters.len().saturating_sub(1) as f32;
let mut cursor = ctx.width() / 2.0 - total / 2.0;
let mut nodes: Vec<Svgr<'_>> = Vec::with_capacity(characters.len());
for (index, ch) in characters.iter().enumerate() {
let width = widths.get(index).copied().unwrap_or(0.0);
let center = cursor + width / 2.0;
cursor += width + tracking_px;
let Some(state) = letter_state(frame, index, characters.len(), *ch, config) else {
continue;
};
let transform = format!(
"translate({:.4} {:.4}) rotate({:.4}) scale({:.6} {:.6})",
center,
baseline + state.translate_y,
state.rotate_deg,
state.scale_x,
state.scale_y,
);
let glyph = fframes::svgr!(
<text x={0.0_f32} y={mid} font-family={family.clone()} font-size={size}
font-weight={config.font_weight} letter-spacing={config.tracking_em}
fill={state.fill} opacity={1.0} text-anchor="middle">
{state.ch.to_string()}
</text>
);
nodes.push(fframes::svgr!(
<g transform={transform} opacity={state.opacity}>{glyph}</g>
));
}
let nodes = Svgr::from(nodes);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f32, b: f32, tolerance: f32) -> bool {
(a - b).abs() <= tolerance
}
#[test]
fn kick_is_a_damped_sine_on_zero_to_one() {
assert!(close(inflate_kick(0.0), 0.0, 1e-6));
assert!(close(inflate_kick(0.5), 0.0, 1e-5), "{}", inflate_kick(0.5));
assert!(inflate_kick(0.7) < 0.0);
assert!(close(inflate_kick(1.0), 0.0, 1e-3), "{}", inflate_kick(1.0));
}
#[test]
fn kick_peak_matches_the_sampled_maximum() {
let peak = inflate_kick_peak();
assert!(close(peak, 0.6375, 1e-3), "peak was {peak}");
for i in 0..=1000 {
assert!(inflate_kick(i as f32 / 1000.0) <= peak + 1e-6);
}
assert!(close(inflate_kick_peak(), peak, 0.0));
}
#[test]
fn letter_scale_starts_at_rest_squashes_then_overshoots() {
let at_start = letter_scale(10.0, 10.0, 1.22);
assert!(close(at_start.scale, 1.0, 1e-6));
assert!(close(at_start.scale_x, 1.0, 1e-6));
assert!(close(at_start.scale_y, 1.0, 1e-6));
let mid_anticipate = letter_scale(12.0, 10.0, 1.22);
assert!(close(mid_anticipate.scale, 1.0, 1e-6));
assert!(mid_anticipate.scale_x > 1.0);
assert!(mid_anticipate.scale_y < 1.0);
assert!(close(mid_anticipate.scale_x, 1.0 + SQUASH, 1e-5));
let after_anticipate = letter_scale(14.0, 10.0, 1.22);
assert!(close(after_anticipate.scale, 1.0, 1e-6));
let peak = letter_scale(14.0 + 0.2 * INFLATE_FRAMES as f32, 10.0, 1.22);
assert!(close(peak.scale, 1.22, 2e-3), "scale was {}", peak.scale);
let under = letter_scale(14.0 + 0.7 * INFLATE_FRAMES as f32, 10.0, 1.22);
assert!(under.scale < 1.0, "scale was {}", under.scale);
let end = letter_scale(
10.0 + (ANTICIPATE_FRAMES + INFLATE_FRAMES) as f32,
10.0,
1.22,
);
assert!(close(end.scale, 1.0, 1e-3), "scale was {}", end.scale);
}
#[test]
fn letter_state_holds_the_accent_tint_and_fades_out() {
let config = InflatingTextConfig::default();
let count = chars(&config.text).len();
assert_eq!(count, 11);
let at_peak =
letter_state(2.0 + 4.0 + 2.8, 0, count, 'M', &config).expect("a letter gets a state");
assert!(at_peak.fill.starts_with('#'));
assert!(
at_peak.translate_y < 0.0,
"the letter lifts at peak inflation"
);
assert!(letter_state(10.0, 0, count, ' ', &config).is_none());
let deflated =
letter_state(200.0, count - 1, count, 'p', &config).expect("a letter gets a state");
assert!(close(deflated.opacity, 0.0, 1e-6));
assert!(close(deflated.scale_x, 1.0 - 0.15, 1e-5));
}
#[test]
fn reduced_motion_drops_every_size_channel_but_keeps_the_tint() {
let config = InflatingTextConfig {
reduced_motion: true,
..InflatingTextConfig::default()
};
let state = letter_state(8.0, 0, 11, 'M', &config).expect("a letter gets a state");
assert!(close(state.scale_x, 1.0, 1e-6));
assert!(close(state.scale_y, 1.0, 1e-6));
assert!(close(state.rotate_deg, 0.0, 1e-6));
assert!(close(state.translate_y, 0.0, 1e-6));
assert_ne!(state.fill, config.color);
}
#[test]
fn duration_matches_the_source_formula() {
assert_eq!(get_inflating_text_duration("Make It Pop", 2, 0, 20, 2), 86);
assert_eq!(
get_inflating_text_duration("A", 2, 0, 20, 2),
4 + 14 + 20 + 8
);
assert_eq!(get_inflating_text_duration("", 2, 0, 20, 2), 1);
assert_eq!(
get_inflating_text_duration("A", 2, 5, 20, 2),
5 + 4 + 14 + 20 + 8
);
assert_eq!(
get_inflating_text_duration("ABC", 0, 0, 20, 3),
4 + 14 + 20 + 2 * 3 + 8
);
}
#[test]
fn component_duration_matches_the_helper() {
let component = InflatingText::default();
assert_eq!(component.duration(), 86);
assert_eq!(component.meta().id, "inflating-text");
assert_eq!(component.meta().feature, "inflating-text");
}
#[test]
fn every_published_constant_keeps_the_source_value() {
assert_eq!(ANTICIPATE_FRAMES, 4);
assert_eq!(INFLATE_FRAMES, 14);
assert_eq!(HOLD_FRAMES, 20);
assert_eq!(DEFLATE_FRAMES, 8);
assert!(close(SQUASH, 0.08, 0.0));
assert!(close(WOBBLE_DEG, 4.0, 0.0));
assert!(close(BOUNCE_EM, 0.05, 0.0));
assert!(close(GOOEY_BLUR_CAP, 0.04, 0.0));
assert!(close(SPEED_NORM, 0.3, 0.0));
assert_eq!(GOOEY_MATRIX, "1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9");
}
}