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}