use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{RenderCtx, text_node_centered, words};
use crate::motion::{CubicBezier, EASE_ZOOM_IN, clamp01};
pub const RISE_EASE: CubicBezier = CubicBezier::new(0.2, 0.6, 0.35, 1.0);
pub const APPROACH_EASE: CubicBezier = CubicBezier::new(0.4, 0.0, 0.15, 1.0);
pub const LETTER_EASE: CubicBezier = CubicBezier::new(0.4, 0.0, 0.2, 1.0);
pub const PUSH_EASE: CubicBezier = CubicBezier::new(0.22, 0.8, 0.36, 1.0);
pub const FIT: f32 = 0.97;
#[derive(Debug, Clone, PartialEq)]
pub struct SwellMetrics {
pub line_width: f32,
pub lead_center: f32,
pub baseline: f32,
pub word_widths: Vec<f32>,
pub space_width: f32,
}
pub fn swell_metrics(word_widths: Vec<f32>, space_width: f32) -> SwellMetrics {
let count = word_widths.len();
let line_width = word_widths.iter().sum::<f32>() + space_width * count.saturating_sub(1) as f32;
let lead_center = word_widths.first().copied().unwrap_or(0.0) / 2.0;
SwellMetrics {
line_width,
lead_center,
baseline: 0.0,
word_widths,
space_width,
}
}
pub fn word_start(j: usize, word_delay: usize, word_stagger: usize) -> f32 {
word_delay as f32 + j as f32 * word_stagger as f32
}
fn progress(numerator: f32, duration: usize) -> f32 {
if duration == 0 {
return if numerator > 0.0 { 1.0 } else { 0.0 };
}
clamp01(numerator / duration as f32)
}
pub fn swell_shares(word_widths: &[f32], space_width: f32, trailing_count: usize) -> Vec<f32> {
if trailing_count == 0 {
return Vec::new();
}
let added: Vec<f32> = (0..trailing_count)
.map(|j| word_widths.get(j + 1).copied().unwrap_or(0.0) + space_width)
.collect();
let total: f32 = added.iter().sum();
if total <= 0.0 {
return vec![0.0; trailing_count];
}
added.iter().map(|w| w / total).collect()
}
pub fn swell_shoved(
frame: f32,
shares: &[f32],
word_delay: usize,
word_stagger: usize,
word_push_duration: usize,
) -> f32 {
let mut shoved = 0.0;
for (j, share) in shares.iter().enumerate() {
let p = PUSH_EASE.ease(progress(
frame - word_start(j, word_delay, word_stagger),
word_push_duration,
));
shoved += share * p;
}
shoved
}
pub fn swell_tx(scale: f32, line_width: f32, lead_center: f32, shoved: f32) -> f32 {
scale * (line_width / 2.0 - lead_center) * (1.0 - shoved)
}
pub fn swell_fit_cap(front_scale: f32, line_width: f32, frame_width: f32) -> f32 {
if line_width > 0.0 {
FIT * frame_width / line_width
} else {
front_scale
}
}
pub fn swell_line_scale(
frame: f32,
approach_delay: usize,
approach_duration: usize,
front_scale: f32,
line_width: f32,
frame_width: f32,
) -> f32 {
let approach_p = APPROACH_EASE.ease(progress(frame - approach_delay as f32, approach_duration));
let front = front_scale.min(swell_fit_cap(front_scale, line_width, frame_width));
if frame < approach_delay as f32 {
1.0
} else {
1.0 + (front - 1.0) * approach_p
}
}
#[allow(clippy::too_many_arguments)]
pub fn swell_recede_start(
trailing_count: usize,
approach_delay: usize,
approach_duration: usize,
word_delay: usize,
word_stagger: usize,
word_push_duration: usize,
hold_duration: usize,
) -> f32 {
let last_end = if trailing_count == 0 {
(approach_delay + approach_duration) as f32
} else {
word_start(trailing_count - 1, word_delay, word_stagger) + word_push_duration as f32
};
last_end.max((approach_delay + approach_duration) as f32) + hold_duration as f32
}
pub fn letter_swell_scale(rise_pulse: f32, fall_pulse: f32, letter_swell: f32) -> f32 {
1.0 + letter_swell * (rise_pulse - fall_pulse)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextSwellTiming {
pub approach_delay: usize,
pub approach_duration: usize,
pub word_delay: usize,
pub word_stagger: usize,
pub word_push_duration: usize,
pub hold_duration: usize,
pub recede_duration: usize,
}
impl Default for TextSwellTiming {
fn default() -> Self {
Self {
approach_delay: 14,
approach_duration: 20,
word_delay: 27,
word_stagger: 14,
word_push_duration: 12,
hold_duration: 6,
recede_duration: 18,
}
}
}
pub fn get_text_swell_duration(text: &str, timing: &TextSwellTiming) -> usize {
let trailing = words(text).len().saturating_sub(1);
let last_end = if trailing == 0 {
timing.approach_delay + timing.approach_duration
} else {
timing.word_delay + (trailing - 1) * timing.word_stagger + timing.word_push_duration
};
last_end.max(timing.approach_delay + timing.approach_duration)
+ timing.hold_duration
+ timing.recede_duration
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextSwellConfig {
pub text: String,
pub rise_distance_em: f32,
pub rise_duration: usize,
pub front_scale: f32,
pub approach_delay: usize,
pub approach_duration: usize,
pub word_delay: usize,
pub word_stagger: usize,
pub word_push_em: f32,
pub word_push_duration: usize,
pub bounce_words: usize,
pub letter_swell: f32,
pub letter_stagger: usize,
pub letter_rise: usize,
pub letter_hold: usize,
pub letter_fall: usize,
pub hold_duration: usize,
pub recede_duration: usize,
pub delay: usize,
pub font_size: f32,
pub font_weight: u16,
pub color: String,
pub tracking_em: f32,
}
impl Default for TextSwellConfig {
fn default() -> Self {
Self {
text: "Big Ideas Move Fast".to_owned(),
rise_distance_em: 0.7,
rise_duration: 10,
front_scale: 2.1,
approach_delay: 14,
approach_duration: 20,
word_delay: 27,
word_stagger: 14,
word_push_em: 0.15,
word_push_duration: 12,
bounce_words: 1,
letter_swell: 0.23,
letter_stagger: 2,
letter_rise: 3,
letter_hold: 0,
letter_fall: 6,
hold_duration: 6,
recede_duration: 18,
delay: 0,
font_size: 72.0,
font_weight: 600,
color: "#e6edf3".to_owned(),
tracking_em: -0.03,
}
}
}
impl TextSwellConfig {
pub fn timing(&self) -> TextSwellTiming {
TextSwellTiming {
approach_delay: self.approach_delay,
approach_duration: self.approach_duration,
word_delay: self.word_delay,
word_stagger: self.word_stagger,
word_push_duration: self.word_push_duration,
hold_duration: self.hold_duration,
recede_duration: self.recede_duration,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TextSwell {
pub config: TextSwellConfig,
}
impl TextSwell {
pub fn new(config: TextSwellConfig) -> Self {
Self { config }
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "text-swell",
name: "Text Swell",
summary: "The lead word is shoved aside by the words that follow it as the line swells.",
feature: "text-swell",
}
}
}
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 TextSwell {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
get_text_swell_duration(&self.config.text, &self.config.timing())
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let family = ctx.sans();
let size = config.font_size;
let frame = (ctx.frame() - config.delay as f32).max(0.0);
let tracking_px = config.tracking_em * size;
let source_words = words(&config.text);
let trailing_count = source_words.len().saturating_sub(1);
let space_width = ctx.space_width(&family, size) + tracking_px;
let word_widths: Vec<f32> = source_words
.iter()
.map(|word| {
let count = word.chars().count();
ctx.text_width(&family, size, word) + tracking_px * count.saturating_sub(1) as f32
})
.collect();
let metrics = swell_metrics(word_widths, space_width);
let scale = swell_line_scale(
frame,
config.approach_delay,
config.approach_duration,
config.front_scale,
metrics.line_width,
ctx.width(),
);
let shares = swell_shares(&metrics.word_widths, metrics.space_width, trailing_count);
let shoved = swell_shoved(
frame,
&shares,
config.word_delay,
config.word_stagger,
config.word_push_duration,
);
let tx = swell_tx(scale, metrics.line_width, metrics.lead_center, shoved);
let recede_start = swell_recede_start(
trailing_count,
config.approach_delay,
config.approach_duration,
config.word_delay,
config.word_stagger,
config.word_push_duration,
config.hold_duration,
);
let recede_p = EASE_ZOOM_IN.ease(progress(frame - recede_start, config.recede_duration));
let final_scale = scale - (scale - 1.0) * recede_p;
let rise_p = RISE_EASE.ease(progress(frame, config.rise_duration));
let lead_y = (1.0 - rise_p) * config.rise_distance_em * size;
let center_x = ctx.width() / 2.0;
let baseline = ctx.height() / 2.0 + em_center_to_baseline(ctx, &family, size);
let mut nodes: Vec<Svgr<'_>> = Vec::with_capacity(source_words.len());
let mut cursor = 0.0_f32;
for (index, word) in source_words.iter().enumerate() {
let width = metrics.word_widths.get(index).copied().unwrap_or(0.0);
let left = cursor;
cursor += width + metrics.space_width;
let word_center = left + width / 2.0;
let visible = index == 0
|| frame >= word_start(index - 1, config.word_delay, config.word_stagger);
let visibility = if visible { "visible" } else { "hidden" }.to_owned();
if index == 0 {
let content = text_node_centered(
word,
word_center,
0.0,
family.clone(),
size,
config.font_weight,
config.tracking_em,
config.color.clone(),
1.0,
);
let transform = format!("translate(0 {lead_y:.4})");
nodes.push(fframes::svgr!(
<g transform={transform} visibility={visibility}>{content}</g>
));
continue;
}
let j = index - 1;
let push_p = PUSH_EASE.ease(progress(
frame - word_start(j, config.word_delay, config.word_stagger),
config.word_push_duration,
));
let push_x = (1.0 - push_p) * config.word_push_em * size;
let transform = format!("translate({push_x:.4} 0)");
if j >= config.bounce_words {
let content = text_node_centered(
word,
word_center,
0.0,
family.clone(),
size,
config.font_weight,
config.tracking_em,
config.color.clone(),
1.0,
);
nodes.push(fframes::svgr!(
<g transform={transform} visibility={visibility}>{content}</g>
));
continue;
}
let start = word_start(j, config.word_delay, config.word_stagger);
let mut letters: Vec<Svgr<'_>> = Vec::new();
let mut letter_left = left;
for (c, ch) in word.chars().enumerate() {
let char_width = ctx.text_width(&family, size, &ch.to_string());
let char_center = letter_left + char_width / 2.0;
letter_left += char_width + tracking_px;
let rise_start = start + c as f32 * config.letter_stagger as f32;
let fall_start = rise_start + config.letter_rise as f32 + config.letter_hold as f32;
let rise_pulse = LETTER_EASE.ease(progress(frame - rise_start, config.letter_rise));
let fall_pulse = LETTER_EASE.ease(progress(frame - fall_start, config.letter_fall));
let letter_scale = letter_swell_scale(rise_pulse, fall_pulse, config.letter_swell);
let transform = format!("translate({char_center:.4} 0) scale({letter_scale:.6})");
let glyph = fframes::svgr!(
<text x={0.0_f32} y={0.0_f32} font-family={family.clone()} font-size={size}
font-weight={config.font_weight} letter-spacing={config.tracking_em}
fill={config.color.clone()} text-anchor="middle">{ch.to_string()}</text>
);
letters.push(fframes::svgr!(<g transform={transform}>{glyph}</g>));
}
let letters = Svgr::from(letters);
nodes.push(fframes::svgr!(
<g transform={transform} visibility={visibility}>{letters}</g>
));
}
let line_transform = format!(
"translate({:.4} {:.4}) scale({final_scale:.6}) translate({:.4} 0)",
center_x + tx,
baseline,
-metrics.line_width / 2.0,
);
let nodes = Svgr::from(nodes);
fframes::svgr!(<g transform={line_transform}>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f32, b: f32, tolerance: f32) -> bool {
(a - b).abs() <= tolerance
}
#[test]
fn named_curves_are_the_source_beziers() {
assert_eq!(RISE_EASE, CubicBezier::new(0.2, 0.6, 0.35, 1.0));
assert_eq!(APPROACH_EASE, CubicBezier::new(0.4, 0.0, 0.15, 1.0));
assert_eq!(LETTER_EASE, CubicBezier::new(0.4, 0.0, 0.2, 1.0));
assert_eq!(PUSH_EASE, CubicBezier::new(0.22, 0.8, 0.36, 1.0));
assert!(close(FIT, 0.97, 0.0));
for curve in [RISE_EASE, APPROACH_EASE, LETTER_EASE, PUSH_EASE] {
assert!(close(curve.ease(0.0), 0.0, 1e-4));
assert!(close(curve.ease(1.0), 1.0, 1e-4));
}
}
#[test]
fn metrics_add_spaces_and_centre_the_lead() {
let metrics = swell_metrics(vec![100.0, 50.0, 25.0], 10.0);
assert!(close(
metrics.line_width,
100.0 + 50.0 + 25.0 + 2.0 * 10.0,
1e-5
));
assert!(close(metrics.lead_center, 50.0, 1e-5));
assert!(close(metrics.baseline, 0.0, 0.0));
let single = swell_metrics(vec![80.0], 10.0);
assert!(close(single.line_width, 80.0, 1e-5));
}
#[test]
fn shares_are_a_partition_of_the_added_width() {
let shares = swell_shares(&[100.0, 50.0, 30.0], 10.0, 2);
assert_eq!(shares.len(), 2);
assert!(close(shares[0], 0.6, 1e-5));
assert!(close(shares[1], 0.4, 1e-5));
assert!(close(shares.iter().sum::<f32>(), 1.0, 1e-5));
assert!(swell_shares(&[100.0], 10.0, 0).is_empty());
let zeros = swell_shares(&[0.0, 0.0], 0.0, 1);
assert!(close(zeros[0], 0.0, 0.0));
}
#[test]
fn shoved_accumulates_each_landed_word() {
let shares = [0.6, 0.4];
assert!(close(swell_shoved(0.0, &shares, 27, 14, 12), 0.0, 1e-5));
assert!(close(swell_shoved(39.0, &shares, 27, 14, 12), 0.6, 1e-4));
assert!(close(swell_shoved(53.0, &shares, 27, 14, 12), 1.0, 1e-4));
let mid = swell_shoved(47.0, &shares, 27, 14, 12);
assert!(mid > 0.6 && mid < 1.0, "mid was {mid}");
}
#[test]
fn tx_keeps_the_lead_centred_while_it_is_alone() {
let tx = swell_tx(2.0, 300.0, 50.0, 0.0);
assert!(close(tx, 2.0 * (150.0 - 50.0), 1e-5));
assert!(close(swell_tx(2.0, 300.0, 50.0, 1.0), 0.0, 1e-5));
assert!(close(swell_tx(2.0, 100.0, 50.0, 0.0), 0.0, 1e-5));
}
#[test]
fn line_scale_floats_forward_and_is_fit_capped() {
assert!(close(
swell_line_scale(0.0, 14, 20, 2.1, 400.0, 1920.0),
1.0,
1e-6
));
assert!(close(
swell_line_scale(34.0, 14, 20, 2.1, 400.0, 1920.0),
2.1,
1e-4
));
assert!(close(
swell_line_scale(34.0, 14, 20, 2.1, 800.0, 1000.0),
FIT * 1000.0 / 800.0,
1e-4
));
assert!(close(swell_fit_cap(2.1, 0.0, 1920.0), 2.1, 0.0));
}
#[test]
fn recede_starts_after_the_last_landing_plus_the_hold() {
assert!(close(
swell_recede_start(3, 14, 20, 27, 14, 12, 6),
67.0 + 6.0,
1e-5
));
assert!(close(
swell_recede_start(0, 14, 20, 27, 14, 12, 6),
34.0 + 6.0,
1e-5
));
assert!(close(
swell_recede_start(1, 14, 20, 2, 1, 2, 6),
34.0 + 6.0,
1e-5
));
}
#[test]
fn letter_bounce_rises_then_falls_back_to_rest() {
assert!(close(letter_swell_scale(0.0, 0.0, 0.23), 1.0, 1e-6));
assert!(close(letter_swell_scale(1.0, 0.0, 0.23), 1.23, 1e-6));
assert!(close(letter_swell_scale(1.0, 1.0, 0.23), 1.0, 1e-6));
}
#[test]
fn duration_matches_the_source_formula() {
let timing = TextSwellTiming::default();
assert_eq!(get_text_swell_duration("Big Ideas Move Fast", &timing), 91);
assert_eq!(get_text_swell_duration("Alone", &timing), 58);
assert_eq!(get_text_swell_duration("Two Words", &timing), 39 + 6 + 18);
assert_eq!(get_text_swell_duration(" ", &timing), 58);
}
#[test]
fn component_duration_uses_its_own_timing() {
let component = TextSwell::default();
assert_eq!(component.duration(), 91);
assert_eq!(component.config.timing(), TextSwellTiming::default());
assert_eq!(component.meta().id, "text-swell");
}
#[test]
fn a_zero_frame_beat_completes_instead_of_dividing_by_zero() {
assert!(close(progress(0.0, 0), 0.0, 0.0));
assert!(close(progress(1.0, 0), 1.0, 0.0));
assert!(close(progress(-1.0, 0), 0.0, 0.0));
assert!(close(progress(5.0, 10), 0.5, 1e-6));
}
#[test]
fn word_start_steps_by_the_stagger() {
assert!(close(word_start(0, 27, 14), 27.0, 1e-6));
assert!(close(word_start(3, 27, 14), 69.0, 1e-6));
}
}