Skip to main content

dioxus_type_animation/
type_animation.rs

1use dioxus::prelude::*;
2
3use crate::{
4    CURSOR_CSS,
5    animation::{
6        AnimationConfig, default_splitter, final_class_name, first_string, normalize_speed,
7        run_animation,
8    },
9    repeat::Repeat,
10    sequence::{SequenceElement, StringSplitter},
11    speed::Speed,
12    wrapper::Wrapper,
13};
14
15/// Dioxus typewriter animation component inspired by `react-type-animation`.
16///
17/// # Basic usage
18///
19/// ```rust,no_run
20/// use dioxus::prelude::*;
21/// use dioxus_type_animation::{Repeat, SequenceElement, Speed, TypeAnimation, Wrapper};
22///
23/// fn App() -> Element {
24///     rsx! {
25///         TypeAnimation {
26///             sequence: vec![
27///                 SequenceElement::from("We produce food for Mice"),
28///                 SequenceElement::from(1000_u64),
29///                 SequenceElement::from("We produce food for Hamsters"),
30///                 SequenceElement::from(1000_u64),
31///                 SequenceElement::from("We produce food for Guinea Pigs"),
32///                 SequenceElement::from(1000_u64),
33///                 SequenceElement::from("We produce food for Chinchillas"),
34///                 SequenceElement::from(1000_u64),
35///             ],
36///             wrapper: Wrapper::Span,
37///             speed: Speed::Preset(50),
38///             style: Some("font-size: 2em; display: inline-block;".to_string()),
39///             repeat: Repeat::Infinite,
40///         }
41///     }
42/// }
43/// ```
44///
45/// # Sequence items
46///
47/// The `sequence` prop accepts text transitions, delays in milliseconds, and
48/// callbacks.
49///
50/// ```rust,no_run
51/// use dioxus::prelude::*;
52/// use dioxus_type_animation::{SequenceElement, TypeAnimation};
53///
54/// fn App() -> Element {
55///     rsx! {
56///         TypeAnimation {
57///             sequence: vec![
58///                 SequenceElement::from("Typing..."),
59///                 SequenceElement::from(750_u64),
60///                 SequenceElement::from(|| println!("Done typing")),
61///                 SequenceElement::from("Finished."),
62///             ],
63///         }
64///     }
65/// }
66/// ```
67///
68/// # Repeat behavior
69///
70/// `Repeat::Count(n)` runs the animation once plus `n` repeats. Use
71/// `Repeat::Infinite` to loop forever.
72///
73/// ```rust,no_run
74/// use dioxus::prelude::*;
75/// use dioxus_type_animation::{Repeat, SequenceElement, TypeAnimation};
76///
77/// fn App() -> Element {
78///     rsx! {
79///         TypeAnimation {
80///             sequence: vec![
81///                 SequenceElement::from("Loop me"),
82///                 SequenceElement::from(1000_u64),
83///             ],
84///             repeat: Repeat::Count(3),
85///         }
86///     }
87/// }
88/// ```
89///
90/// # Speed options
91///
92/// `Speed::Preset(value)` mirrors the React library's numeric `speed` prop and
93/// normalizes to `abs(value - 100)` milliseconds per keystroke. Use
94/// `Speed::KeyStrokeDelayInMs(value)` for a direct base delay in milliseconds.
95///
96/// ```rust,no_run
97/// use dioxus::prelude::*;
98/// use dioxus_type_animation::{SequenceElement, Speed, TypeAnimation};
99///
100/// fn App() -> Element {
101///     rsx! {
102///         TypeAnimation {
103///             sequence: vec![
104///                 SequenceElement::from("Type quickly"),
105///                 SequenceElement::from(500_u64),
106///                 SequenceElement::from("Delete slowly"),
107///             ],
108///             speed: Speed::Preset(80),
109///             deletion_speed: Some(Speed::KeyStrokeDelayInMs(120)),
110///         }
111///     }
112/// }
113/// ```
114///
115/// # Omit deletion animation
116///
117/// Set `omit_deletion_animation` to skip animated deletion steps and only
118/// animate newly written text.
119///
120/// ```rust,no_run
121/// use dioxus::prelude::*;
122/// use dioxus_type_animation::{SequenceElement, TypeAnimation};
123///
124/// fn App() -> Element {
125///     rsx! {
126///         TypeAnimation {
127///             sequence: vec![
128///                 SequenceElement::from("Dioxus is fun"),
129///                 SequenceElement::from(1000_u64),
130///                 SequenceElement::from("Dioxus is fast"),
131///             ],
132///             omit_deletion_animation: true,
133///         }
134///     }
135/// }
136/// ```
137///
138/// # Wrapper elements
139///
140/// The default wrapper is [`Wrapper::Span`]. You can choose from `p`, `div`,
141/// `span`, `strong`, `a`, `h1`-`h6`, and `b` via [`Wrapper`].
142///
143/// ```rust,no_run
144/// use dioxus::prelude::*;
145/// use dioxus_type_animation::{SequenceElement, TypeAnimation, Wrapper};
146///
147/// fn App() -> Element {
148///     rsx! {
149///         TypeAnimation {
150///             wrapper: Wrapper::H1,
151///             sequence: vec![SequenceElement::from("Animated heading")],
152///         }
153///     }
154/// }
155/// ```
156///
157/// # Cursor styling
158///
159/// The blinking cursor is enabled by default. Disable it with `cursor: false`.
160///
161/// ```rust,no_run
162/// use dioxus::prelude::*;
163/// use dioxus_type_animation::{SequenceElement, TypeAnimation};
164///
165/// fn App() -> Element {
166///     rsx! {
167///         TypeAnimation {
168///             sequence: vec![SequenceElement::from("No cursor")],
169///             cursor: false,
170///         }
171///     }
172/// }
173/// ```
174///
175/// # Pre-render the first string
176///
177/// Set `pre_render_first_string` to render the first string immediately before
178/// animation starts.
179///
180/// ```rust,no_run
181/// use dioxus::prelude::*;
182/// use dioxus_type_animation::{SequenceElement, TypeAnimation};
183///
184/// fn App() -> Element {
185///     rsx! {
186///         TypeAnimation {
187///             sequence: vec![
188///                 SequenceElement::from("Already visible"),
189///                 SequenceElement::from(1000_u64),
190///                 SequenceElement::from("Then animated"),
191///             ],
192///             pre_render_first_string: true,
193///         }
194///     }
195/// }
196/// ```
197///
198/// # Accessibility attributes
199///
200/// You can pass `aria_label`, `aria_hidden`, and `role`. When `aria_label` is
201/// set, the animated visual text is rendered inside an inner
202/// `aria-hidden="true"` span.
203///
204/// ```rust,no_run
205/// use dioxus::prelude::*;
206/// use dioxus_type_animation::{SequenceElement, TypeAnimation};
207///
208/// fn App() -> Element {
209///     rsx! {
210///         TypeAnimation {
211///             sequence: vec![SequenceElement::from("Fast-changing animated text")],
212///             aria_label: Some("Animated product tagline".to_string()),
213///             role: Some("text".to_string()),
214///         }
215///     }
216/// }
217/// ```
218///
219/// # Custom splitter
220///
221/// The default splitter uses `text.chars()`. Provide a custom [`StringSplitter`]
222/// for grapheme-aware animation or other splitting behavior.
223///
224/// ```rust,no_run
225/// use dioxus::prelude::*;
226/// use dioxus_type_animation::{SequenceElement, StringSplitter, TypeAnimation};
227/// use std::rc::Rc;
228///
229/// fn App() -> Element {
230///     let splitter: StringSplitter = Rc::new(|text: &str| {
231///         text.chars().map(|char| char.to_string()).collect()
232///     });
233///
234///     rsx! {
235///         TypeAnimation {
236///             sequence: vec![SequenceElement::from("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ family")],
237///             splitter: Some(splitter),
238///         }
239///     }
240/// }
241/// ```
242pub fn TypeAnimation(props: TypeAnimationProps) -> Element {
243    let initial_text = if props.pre_render_first_string {
244        first_string(&props.sequence).unwrap_or_default()
245    } else {
246        String::new()
247    };
248
249    let mut displayed = use_signal(|| initial_text.clone());
250
251    {
252        let sequence = props.sequence.clone();
253        let repeat = props.repeat;
254        let speed = normalize_speed(props.speed);
255        let deletion_speed = normalize_speed(props.deletion_speed.unwrap_or(props.speed));
256        let omit_deletion_animation = props.omit_deletion_animation;
257        let splitter = props.splitter.clone().unwrap_or_else(default_splitter);
258        let starting_text = initial_text;
259
260        use_future(move || {
261            let sequence = sequence.clone();
262            let splitter = splitter.clone();
263            let starting_text = starting_text.clone();
264            async move {
265                let config = AnimationConfig {
266                    repeat,
267                    speed,
268                    deletion_speed,
269                    omit_deletion_animation,
270                };
271
272                run_animation(&sequence, &splitter, config, starting_text, &mut displayed).await;
273            }
274        });
275    }
276
277    let text = displayed.read().clone();
278    let class = final_class_name(props.cursor, props.class.as_deref());
279    let style = props.style.unwrap_or_default();
280    let aria_label = props.aria_label.unwrap_or_default();
281    let aria_hidden = props.aria_hidden.unwrap_or_default();
282    let role = props.role.unwrap_or_default();
283    let use_inner_accessibility_span = !aria_label.is_empty();
284    let render_data = RenderData {
285        text,
286        class,
287        style,
288        aria_label,
289        aria_hidden,
290        role,
291        use_inner_accessibility_span,
292    };
293
294    match props.wrapper {
295        Wrapper::P => render_p(render_data),
296        Wrapper::Div => render_div(render_data),
297        Wrapper::Span => render_span(render_data),
298        Wrapper::Strong => render_strong(render_data),
299        Wrapper::A => render_a(render_data),
300        Wrapper::H1 => render_h1(render_data),
301        Wrapper::H2 => render_h2(render_data),
302        Wrapper::H3 => render_h3(render_data),
303        Wrapper::H4 => render_h4(render_data),
304        Wrapper::H5 => render_h5(render_data),
305        Wrapper::H6 => render_h6(render_data),
306        Wrapper::B => render_b(render_data),
307    }
308}
309/// Props for [`TypeAnimation`](crate::TypeAnimation).
310///
311/// The component intentionally compares props as always equal, matching the
312/// React implementation's permanent memoization/immutability behavior. If you
313/// need changed props to take effect, mount a new component instance with a
314/// different key.
315#[derive(Clone, Props)]
316pub struct TypeAnimationProps {
317    /// Animation sequence: text, delays in milliseconds, and callbacks.
318    pub sequence: Vec<SequenceElement>,
319
320    /// Finite or infinite repeat behavior. Default: no repeats.
321    #[props(default)]
322    pub repeat: Repeat,
323
324    /// Wrapper element. Default: [`Wrapper::Span`].
325    #[props(default)]
326    pub wrapper: Wrapper,
327
328    /// Show the default blinking cursor. Default: `true`.
329    #[props(default = true)]
330    pub cursor: bool,
331
332    /// Typing speed. Default: `Speed::Preset(40)`.
333    #[props(default)]
334    pub speed: Speed,
335
336    /// Deletion speed. Default: same as `speed`.
337    #[props(default)]
338    pub deletion_speed: Option<Speed>,
339
340    /// If true, deletions are instant and only writing is animated.
341    #[props(default)]
342    pub omit_deletion_animation: bool,
343
344    /// If true, initially render the first string in `sequence` without typing
345    /// it. Default matches the React source: `false`.
346    #[props(default)]
347    pub pre_render_first_string: bool,
348
349    /// Optional custom splitter. Default: `text.chars()`.
350    #[props(default)]
351    pub splitter: Option<StringSplitter>,
352
353    /// Class applied to the wrapper.
354    #[props(default)]
355    pub class: Option<String>,
356
357    /// Inline style string applied to the wrapper.
358    #[props(default)]
359    pub style: Option<String>,
360
361    /// `aria-label` applied to the wrapper. When set, the animated visual text
362    /// is rendered in an inner `aria-hidden="true"` span.
363    #[props(default)]
364    pub aria_label: Option<String>,
365
366    /// `aria-hidden` applied to the wrapper.
367    #[props(default)]
368    pub aria_hidden: Option<String>,
369
370    /// ARIA role applied to the wrapper.
371    #[props(default)]
372    pub role: Option<String>,
373}
374
375impl PartialEq for TypeAnimationProps {
376    fn eq(&self, _other: &Self) -> bool {
377        true
378    }
379}
380
381struct RenderData {
382    text: String,
383    class: String,
384    style: String,
385    aria_label: String,
386    aria_hidden: String,
387    role: String,
388    use_inner_accessibility_span: bool,
389}
390
391fn render_p(data: RenderData) -> Element {
392    let RenderData {
393        text,
394        class,
395        style,
396        aria_label,
397        aria_hidden,
398        role,
399        use_inner_accessibility_span,
400    } = data;
401    rsx! {
402        style { {CURSOR_CSS} }
403        p { class, style, role, "aria-label": aria_label, "aria-hidden": aria_hidden,
404            if use_inner_accessibility_span { span { "aria-hidden": "true", "{text}" } } else { "{text}" }
405        }
406    }
407}
408
409fn render_div(data: RenderData) -> Element {
410    let RenderData {
411        text,
412        class,
413        style,
414        aria_label,
415        aria_hidden,
416        role,
417        use_inner_accessibility_span,
418    } = data;
419    rsx! {
420        style { {CURSOR_CSS} }
421        div { class, style, role, "aria-label": aria_label, "aria-hidden": aria_hidden,
422            if use_inner_accessibility_span { span { "aria-hidden": "true", "{text}" } } else { "{text}" }
423        }
424    }
425}
426
427fn render_span(data: RenderData) -> Element {
428    let RenderData {
429        text,
430        class,
431        style,
432        aria_label,
433        aria_hidden,
434        role,
435        use_inner_accessibility_span,
436    } = data;
437    rsx! {
438        style { {CURSOR_CSS} }
439        span { class, style, role, "aria-label": aria_label, "aria-hidden": aria_hidden,
440            if use_inner_accessibility_span { span { "aria-hidden": "true", "{text}" } } else { "{text}" }
441        }
442    }
443}
444
445fn render_strong(data: RenderData) -> Element {
446    let RenderData {
447        text,
448        class,
449        style,
450        aria_label,
451        aria_hidden,
452        role,
453        use_inner_accessibility_span,
454    } = data;
455    rsx! {
456        style { {CURSOR_CSS} }
457        strong { class, style, role, "aria-label": aria_label, "aria-hidden": aria_hidden,
458            if use_inner_accessibility_span { span { "aria-hidden": "true", "{text}" } } else { "{text}" }
459        }
460    }
461}
462
463fn render_a(data: RenderData) -> Element {
464    let RenderData {
465        text,
466        class,
467        style,
468        aria_label,
469        aria_hidden,
470        role,
471        use_inner_accessibility_span,
472    } = data;
473    rsx! {
474        style { {CURSOR_CSS} }
475        a { class, style, role, "aria-label": aria_label, "aria-hidden": aria_hidden,
476            if use_inner_accessibility_span { span { "aria-hidden": "true", "{text}" } } else { "{text}" }
477        }
478    }
479}
480
481fn render_h1(data: RenderData) -> Element {
482    let RenderData {
483        text,
484        class,
485        style,
486        aria_label,
487        aria_hidden,
488        role,
489        use_inner_accessibility_span,
490    } = data;
491    rsx! {
492        style { {CURSOR_CSS} }
493        h1 { class, style, role, "aria-label": aria_label, "aria-hidden": aria_hidden,
494            if use_inner_accessibility_span { span { "aria-hidden": "true", "{text}" } } else { "{text}" }
495        }
496    }
497}
498
499fn render_h2(data: RenderData) -> Element {
500    let RenderData {
501        text,
502        class,
503        style,
504        aria_label,
505        aria_hidden,
506        role,
507        use_inner_accessibility_span,
508    } = data;
509    rsx! {
510        style { {CURSOR_CSS} }
511        h2 { class, style, role, "aria-label": aria_label, "aria-hidden": aria_hidden,
512            if use_inner_accessibility_span { span { "aria-hidden": "true", "{text}" } } else { "{text}" }
513        }
514    }
515}
516
517fn render_h3(data: RenderData) -> Element {
518    let RenderData {
519        text,
520        class,
521        style,
522        aria_label,
523        aria_hidden,
524        role,
525        use_inner_accessibility_span,
526    } = data;
527    rsx! {
528        style { {CURSOR_CSS} }
529        h3 { class, style, role, "aria-label": aria_label, "aria-hidden": aria_hidden,
530            if use_inner_accessibility_span { span { "aria-hidden": "true", "{text}" } } else { "{text}" }
531        }
532    }
533}
534
535fn render_h4(data: RenderData) -> Element {
536    let RenderData {
537        text,
538        class,
539        style,
540        aria_label,
541        aria_hidden,
542        role,
543        use_inner_accessibility_span,
544    } = data;
545    rsx! {
546        style { {CURSOR_CSS} }
547        h4 { class, style, role, "aria-label": aria_label, "aria-hidden": aria_hidden,
548            if use_inner_accessibility_span { span { "aria-hidden": "true", "{text}" } } else { "{text}" }
549        }
550    }
551}
552
553fn render_h5(data: RenderData) -> Element {
554    let RenderData {
555        text,
556        class,
557        style,
558        aria_label,
559        aria_hidden,
560        role,
561        use_inner_accessibility_span,
562    } = data;
563    rsx! {
564        style { {CURSOR_CSS} }
565        h5 { class, style, role, "aria-label": aria_label, "aria-hidden": aria_hidden,
566            if use_inner_accessibility_span { span { "aria-hidden": "true", "{text}" } } else { "{text}" }
567        }
568    }
569}
570
571fn render_h6(data: RenderData) -> Element {
572    let RenderData {
573        text,
574        class,
575        style,
576        aria_label,
577        aria_hidden,
578        role,
579        use_inner_accessibility_span,
580    } = data;
581    rsx! {
582        style { {CURSOR_CSS} }
583        h6 { class, style, role, "aria-label": aria_label, "aria-hidden": aria_hidden,
584            if use_inner_accessibility_span { span { "aria-hidden": "true", "{text}" } } else { "{text}" }
585        }
586    }
587}
588
589fn render_b(data: RenderData) -> Element {
590    let RenderData {
591        text,
592        class,
593        style,
594        aria_label,
595        aria_hidden,
596        role,
597        use_inner_accessibility_span,
598    } = data;
599    rsx! {
600        style { {CURSOR_CSS} }
601        b { class, style, role, "aria-label": aria_label, "aria-hidden": aria_hidden,
602            if use_inner_accessibility_span { span { "aria-hidden": "true", "{text}" } } else { "{text}" }
603        }
604    }
605}