use dioxus::prelude::*;
pub use dioxus_textfx_core::{
ReducedMotion, TextCfg, TextEase, TextEffect, TextFxChoreography, TextFxConfig,
TextFxDirection, TextFxEasing, TextFxEffect, TextFxGpuBudget, TextFxLayoutReserve,
TextFxLiveContrast, TextFxLoop, TextFxPerformanceProfile, TextFxPlayback, TextFxProfile,
TextFxRenderPreference, TextFxTiming, TextFxTrigger, TextProfile, TextSplit, TokenAction,
TokenMark, TokenTarget, fx, text_fx, textfx, timing,
};
pub use BlurReveal as Blur;
pub use CountUpText as Count;
pub use LocaleTransition as LocaleText;
pub use ScrambleText as Scramble;
pub use SplitText as Split;
pub use StaggerText as Stagger;
pub use TextFx as Text;
pub use Typewriter as Type;
pub mod prelude {
pub use dioxus_textfx_core::prelude::*;
pub use crate::{
Blur, BlurReveal, Count, CountUpText, LocaleText, LocaleTransition, ReducedMotion,
Scramble, ScrambleText, Split, SplitText, Stagger, StaggerText, Text, TextCfg, TextEase,
TextEffect, TextFx, TextFxConfig, TextFxEffect, TextFxProfile, TextFxRenderPreference,
TextFxTiming, TextFxTrigger, TextProfile, TextSplit, Type, Typewriter, fx, text_fx, textfx,
textfx_component_explain, textfx_component_manifest, textfx_native_integration_hints,
timing,
};
}
pub mod dx {
pub use crate::prelude::*;
pub use dioxus_motion_core::dx::DurationDx;
pub fn text(
id: impl Into<String>,
value: impl Into<String>,
) -> dioxus_textfx_core::TextFxConfig {
dioxus_textfx_core::TextFxConfig::new(id, value)
}
pub fn text_id(id: impl Into<String>) -> dioxus_textfx_core::TextFxConfig {
dioxus_textfx_core::TextFxConfig::new(id, "")
}
pub fn timing() -> dioxus_textfx_core::TextFxTiming {
dioxus_textfx_core::TextFxTiming::default()
}
}
pub const TEXTFX_THEME_CHANGE_EVENT: &str = dioxus_theme_core::THEME_CHANGE_EVENT;
pub fn textfx_component_manifest<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
policy: &dioxus_textfx_core::TextFxRoutePolicy,
) -> dioxus_textfx_core::TextFxManifestFragment {
dioxus_textfx_core::textfx_manifest_fragment(configs, policy)
}
pub fn textfx_component_explain<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
policy: &dioxus_textfx_core::TextFxRoutePolicy,
) -> dioxus_textfx_core::TextFxExplainReport {
dioxus_textfx_core::explain_textfx(configs, policy)
}
pub fn textfx_native_integration_hints<'a>(
configs: impl IntoIterator<Item = &'a TextFxConfig>,
policy: &dioxus_textfx_core::TextFxRoutePolicy,
) -> std::collections::BTreeMap<String, String> {
let configs = configs.into_iter().collect::<Vec<_>>();
let mut hints = dioxus_textfx_core::textfx_native_port_hints(configs.iter().copied(), policy);
hints.insert(
"nativeActions".to_string(),
textfx_native_package_actions(policy.route.as_deref())
.len()
.to_string(),
);
hints.insert(
"nativePackage".to_string(),
textfx_native_compatibility_manifest().package,
);
hints
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TextFxThemeTokenInterop {
pub change_event: &'static str,
pub gradient_keys: [&'static str; 3],
pub gradient_tokens: [&'static str; 3],
pub text_token: &'static str,
}
pub const fn textfx_theme_token_interop() -> TextFxThemeTokenInterop {
TextFxThemeTokenInterop {
change_event: dioxus_theme_core::THEME_CHANGE_EVENT,
gradient_keys: ["accent", "text", "muted"],
gradient_tokens: [
dioxus_theme_core::THEME_TOKEN_ACCENT,
dioxus_theme_core::THEME_TOKEN_TEXT,
dioxus_theme_core::THEME_TOKEN_MUTED,
],
text_token: dioxus_theme_core::THEME_TOKEN_TEXT,
}
}
pub fn textfx_theme_gradient_css_vars() -> [&'static str; 3] {
textfx_theme_token_interop().gradient_tokens
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextFxRuntimeMode {
BrowserRuntime,
StaticFallback,
}
pub fn textfx_runtime_mode() -> TextFxRuntimeMode {
if cfg!(all(feature = "web", target_arch = "wasm32")) {
TextFxRuntimeMode::BrowserRuntime
} else {
TextFxRuntimeMode::StaticFallback
}
}
pub fn textfx_native_fallback_config(
id: impl Into<String>,
text: impl Into<String>,
) -> TextFxConfig {
TextFxConfig::new(id, text).with_reduced_motion(ReducedMotion::Static)
}
pub fn textfx_native_compatibility_manifest() -> dioxus_native_port::VisualCompatibilityManifest {
dioxus_native_port::native_port_visual_compatibility_manifest("dioxus-textfx")
.expect("dioxus-textfx visual compatibility manifest is registered")
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextFxNativeAction {
SplitTokens,
RunTimeline,
CountUp,
LocaleTransition,
}
impl TextFxNativeAction {
pub const fn as_str(self) -> &'static str {
match self {
Self::SplitTokens => "split-tokens",
Self::RunTimeline => "run-timeline",
Self::CountUp => "count-up",
Self::LocaleTransition => "locale-transition",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::SplitTokens => "Split tokens",
Self::RunTimeline => "Run native timeline",
Self::CountUp => "Count up",
Self::LocaleTransition => "Locale transition",
}
}
}
pub fn textfx_native_package_actions(
route: Option<&str>,
) -> Vec<dioxus_native_port::NativePackageAction> {
let route = route.map(str::to_string);
[
TextFxNativeAction::SplitTokens,
TextFxNativeAction::RunTimeline,
TextFxNativeAction::CountUp,
TextFxNativeAction::LocaleTransition,
]
.into_iter()
.map(|action| {
let mut package_action = dioxus_native_port::NativePackageAction::new(
"dioxus-textfx",
action.as_str(),
action.label(),
dioxus_native_port::NativeActionKind::NativeAction,
)
.description("Runs the text effect through native Dioxus state/timeline data.");
if let Some(route) = route.clone() {
package_action = package_action.route(route);
}
package_action
})
.collect()
}
pub fn textfx_native_action(
config: &TextFxConfig,
action: TextFxNativeAction,
) -> dioxus_native_port::NativeActionResult {
let tokens = split_textfx_tokens(&config.text, config.split);
let timeline_steps = match action {
TextFxNativeAction::SplitTokens => tokens.len().max(1),
TextFxNativeAction::RunTimeline => tokens.len().max(1) + 2,
TextFxNativeAction::CountUp => 8,
TextFxNativeAction::LocaleTransition => tokens.len().max(1) + 1,
};
let final_text = match action {
TextFxNativeAction::CountUp => config
.to
.map(|value| value.to_string())
.unwrap_or_else(|| config.text.clone()),
_ => config.text.clone(),
};
let worker_mode = if config.requires_workertown_render() {
"workertown-render"
} else {
"native-state"
};
dioxus_native_port::NativeActionResult::succeeded(
"dioxus-textfx",
action.as_str(),
dioxus_native_port::NativeActionKind::NativeAction,
format!(
"{} prepared for native renderer state updates",
action.label()
),
)
.with_backend(worker_mode)
.with_output("effect", config.effect.as_attr())
.with_output("split", text_split_attr(config.split))
.with_output("tokenCount", tokens.len().to_string())
.with_output("timelineSteps", timeline_steps.to_string())
.with_output("durationMs", config.timing.duration_ms.to_string())
.with_output("finalText", final_text)
}
fn split_textfx_tokens(text: &str, split: TextSplit) -> Vec<String> {
match split {
TextSplit::None => vec![text.to_string()],
TextSplit::Chars => text.chars().map(|ch| ch.to_string()).collect(),
TextSplit::Words => text.split_whitespace().map(str::to_string).collect(),
TextSplit::Lines => text.lines().map(str::to_string).collect(),
}
}
fn text_split_attr(split: TextSplit) -> &'static str {
match split {
TextSplit::None => "none",
TextSplit::Chars => "chars",
TextSplit::Words => "words",
TextSplit::Lines => "lines",
}
}
#[derive(Props, Clone, PartialEq)]
pub struct TextFxProps {
pub text: String,
#[props(default)]
pub effect: TextFxEffect,
#[props(default)]
pub timing: TextFxTiming,
#[props(default)]
pub split: TextSplit,
#[props(default)]
pub performance_profile: TextFxPerformanceProfile,
#[props(default)]
pub gpu_budget: TextFxGpuBudget,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub fx: String,
#[props(default = "span".to_string())]
pub as_tag: String,
#[props(default)]
pub class: String,
#[props(default)]
pub id: String,
}
#[component]
pub fn TextFx(props: TextFxProps) -> Element {
let id = textfx_id(&props.id, &props.text);
let config = if props.fx.trim().is_empty() {
let config = TextFxConfig::new(id, props.text.clone())
.with_performance_profile(props.performance_profile)
.with_gpu_budget(props.gpu_budget)
.with_layout_reserve(props.layout_reserve)
.with_effect(props.effect)
.with_timing(props.timing);
if props.split == TextSplit::None {
config
} else {
config.with_split(props.split)
}
} else {
TextFxConfig::from_fx(id, props.text.clone(), props.fx.clone())
.unwrap_or_else(|_| TextFxConfig::new("", props.text.clone()))
};
render_textfx_node(&props.as_tag, &props.class, &config.text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct SplitTextProps {
pub text: String,
#[props(default = TextSplit::Chars)]
pub by: TextSplit,
#[props(default = TextFxEffect::Stagger)]
pub effect: TextFxEffect,
#[props(default = 28)]
pub stagger_ms: u32,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub class: String,
}
#[component]
pub fn SplitText(props: SplitTextProps) -> Element {
let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
.with_effect(props.effect)
.with_split(props.by)
.with_layout_reserve(props.layout_reserve)
.with_stagger_ms(props.stagger_ms);
render_textfx_node("span", &props.class, &config.text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct TypewriterProps {
pub text: String,
#[props(default = 32)]
pub speed_ms: u32,
#[props(default = true)]
pub cursor: bool,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub class: String,
}
#[component]
pub fn Typewriter(props: TypewriterProps) -> Element {
let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
.with_effect(TextFxEffect::Typewriter)
.with_layout_reserve(props.layout_reserve)
.with_speed_ms(props.speed_ms)
.with_cursor(props.cursor);
render_textfx_node("span", &props.class, &config.text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct ScrambleTextProps {
pub text: String,
#[props(default = dioxus_textfx_core::DEFAULT_TEXTFX_CHARSET.to_string())]
pub charset: String,
#[props(default = 32)]
pub speed_ms: u32,
#[props(default = 520)]
pub settle_ms: u32,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub class: String,
}
#[component]
pub fn ScrambleText(props: ScrambleTextProps) -> Element {
let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
.with_effect(TextFxEffect::Scramble)
.with_layout_reserve(props.layout_reserve)
.with_charset(props.charset)
.with_speed_ms(props.speed_ms)
.with_duration_ms(props.settle_ms);
render_textfx_node("span", &props.class, &config.text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct BlurRevealProps {
pub text: String,
#[props(default = 640)]
pub duration_ms: u32,
#[props(default)]
pub easing: TextFxEasing,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub class: String,
}
#[component]
pub fn BlurReveal(props: BlurRevealProps) -> Element {
let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
.with_effect(TextFxEffect::BlurReveal)
.with_layout_reserve(props.layout_reserve)
.with_duration_ms(props.duration_ms)
.with_easing(props.easing);
render_textfx_node("span", &props.class, &config.text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct StaggerTextProps {
pub text: String,
#[props(default = TextSplit::Words)]
pub by: TextSplit,
#[props(default = 28)]
pub delay_ms: u32,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub class: String,
}
#[component]
pub fn StaggerText(props: StaggerTextProps) -> Element {
let config = TextFxConfig::new(textfx_id("", &props.text), props.text.clone())
.with_effect(TextFxEffect::Stagger)
.with_split(props.by)
.with_layout_reserve(props.layout_reserve)
.with_stagger_ms(props.delay_ms);
render_textfx_node("span", &props.class, &config.text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct CountUpTextProps {
pub from: f64,
pub to: f64,
#[props(default = 900)]
pub duration_ms: u32,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub class: String,
}
#[component]
pub fn CountUpText(props: CountUpTextProps) -> Element {
let text = format!("{}", props.to);
let config = TextFxConfig::new(textfx_id("", &text), text.clone())
.with_effect(TextFxEffect::CountUp)
.with_layout_reserve(props.layout_reserve)
.with_duration_ms(props.duration_ms)
.with_numbers(props.from, props.to);
render_textfx_node("span", &props.class, &text, &config)
}
#[derive(Props, Clone, PartialEq)]
pub struct LocaleTransitionProps {
pub text: String,
pub key_name: String,
#[props(default = TextFxEffect::BlurReveal)]
pub effect: TextFxEffect,
#[props(default)]
pub timing: TextFxTiming,
#[props(default)]
pub split: TextSplit,
#[props(default)]
pub performance_profile: TextFxPerformanceProfile,
#[props(default)]
pub gpu_budget: TextFxGpuBudget,
#[props(default)]
pub layout_reserve: TextFxLayoutReserve,
#[props(default)]
pub fx: String,
#[props(default)]
pub class: String,
#[props(default)]
pub id: String,
}
#[component]
pub fn LocaleTransition(props: LocaleTransitionProps) -> Element {
let class = join_class("dxt-textfx", &props.class);
let id = textfx_id(&props.id, &props.text);
let config = if props.fx.trim().is_empty() {
let config = TextFxConfig::new(id, props.text.clone())
.with_performance_profile(props.performance_profile)
.with_gpu_budget(props.gpu_budget)
.with_layout_reserve(props.layout_reserve)
.with_effect(props.effect)
.with_timing(props.timing);
if props.split == TextSplit::None {
config
} else {
config.with_split(props.split)
}
} else {
TextFxConfig::from_fx(id, props.text.clone(), props.fx.clone())
.unwrap_or_else(|_| TextFxConfig::new("", props.text.clone()).with_effect(props.effect))
};
let effect = config.effect.as_attr();
let locale_fx = config
.to_compact_json()
.unwrap_or_else(|_| "{}".to_string());
if config.reserves_layout() {
let layout_target = config.layout_reserve.as_attr();
rsx! {
span {
class: "{class}",
"data-dxr-i18n-key": "{props.key_name}",
"data-dxt-locale-fx": "{locale_fx}",
"data-dxr-text-layout-target": "{layout_target}",
aria_label: "{props.text}",
title: "{effect}",
"{props.text}"
}
}
} else {
rsx! {
span {
class: "{class}",
"data-dxr-i18n-key": "{props.key_name}",
"data-dxt-locale-fx": "{locale_fx}",
aria_label: "{props.text}",
title: "{effect}",
"{props.text}"
}
}
}
}
fn render_textfx_node(tag: &str, class: &str, text: &str, config: &TextFxConfig) -> Element {
let class = join_class(
if config.effect == TextFxEffect::LiveContrast {
"dxt-textfx dxt-live-contrast"
} else {
"dxt-textfx"
},
class,
);
let effect = config.effect.as_attr();
let layout_target = config.layout_reserve.as_attr();
let reserve_layout = config.reserves_layout();
match tag {
"h1" if reserve_layout => {
rsx! { h1 { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"h1" => {
rsx! { h1 { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"h2" if reserve_layout => {
rsx! { h2 { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"h2" => {
rsx! { h2 { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"h3" if reserve_layout => {
rsx! { h3 { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"h3" => {
rsx! { h3 { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"p" if reserve_layout => {
rsx! { p { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"p" => {
rsx! { p { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"strong" if reserve_layout => {
rsx! { strong { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
"strong" => {
rsx! { strong { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
_ if reserve_layout => {
rsx! { span { class: "{class}", "data-dxr-text-layout-target": "{layout_target}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
_ => {
rsx! { span { class: "{class}", aria_label: "{text}", title: "{effect}", "{text}" } }
}
}
}
fn textfx_id(id: &str, text: &str) -> String {
if !id.trim().is_empty() {
return id.to_string();
}
let slug: String = text
.chars()
.filter_map(|ch| {
if ch.is_ascii_alphanumeric() {
Some(ch.to_ascii_lowercase())
} else if ch.is_whitespace() || ch == '-' {
Some('-')
} else {
None
}
})
.take(48)
.collect();
format!("textfx-{}", slug.trim_matches('-'))
}
fn join_class(base: &str, extra: &str) -> String {
if extra.trim().is_empty() {
base.to_string()
} else {
format!("{base} {}", extra.trim())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn native_fallback_config_is_static_semantic_text() {
let config = textfx_native_fallback_config("title", "Readable");
assert_eq!(textfx_runtime_mode(), TextFxRuntimeMode::StaticFallback);
assert_eq!(config.reduced_motion, ReducedMotion::Static);
assert_eq!(config.text, "Readable");
}
#[test]
fn dx_text_id_supports_deferred_content() {
let config = crate::dx::text_id("headline").content("Launch ready");
assert_eq!(config.id, "headline");
assert_eq!(config.text, "Launch ready");
}
#[test]
fn native_textfx_action_reports_tokens_and_timeline() {
let config = TextFxConfig::new("title", "Native text actions")
.with_effect(TextFxEffect::Typewriter)
.with_split(TextSplit::Words);
let result = textfx_native_action(&config, TextFxNativeAction::RunTimeline);
assert_eq!(
result.status,
dioxus_native_port::NativeActionStatus::Succeeded
);
assert_eq!(
result.kind,
dioxus_native_port::NativeActionKind::NativeAction
);
assert_eq!(
result.outputs.get("tokenCount").map(String::as_str),
Some("3")
);
assert_eq!(
result.outputs.get("timelineSteps").map(String::as_str),
Some("5")
);
}
#[test]
fn native_textfx_package_actions_are_route_scoped() {
let actions = textfx_native_package_actions(Some("/textfx"));
let manifest = textfx_native_compatibility_manifest();
assert_eq!(actions.len(), 4);
assert_eq!(manifest.package, "dioxus-textfx");
assert!(
actions
.iter()
.all(|action| action.route.as_deref() == Some("/textfx"))
);
}
#[test]
fn component_manifest_wraps_core_policy_and_native_hints() {
let config = TextFxConfig::new("headline", "Launch ready").scramble();
let policy = dioxus_textfx_core::textfx_route_policy()
.route("/textfx")
.tag("native");
let manifest = textfx_component_manifest([&config], &policy);
let explain = textfx_component_explain([&config], &policy);
let hints = textfx_native_integration_hints([&config], &policy);
assert_eq!(manifest.route.as_deref(), Some("/textfx"));
assert_eq!(explain.manifest.cache_key, manifest.cache_key);
assert_eq!(hints["nativePackage"], "dioxus-textfx");
assert!(hints["nativeActions"].parse::<usize>().unwrap() >= 1);
}
#[test]
fn theme_token_interop_metadata_uses_shared_contract() {
let interop = textfx_theme_token_interop();
assert_eq!(interop.change_event, "dioxus-theme:change");
assert_eq!(interop.gradient_keys, ["accent", "text", "muted"]);
assert_eq!(
interop.gradient_tokens,
[
dioxus_theme_core::THEME_TOKEN_ACCENT,
dioxus_theme_core::THEME_TOKEN_TEXT,
dioxus_theme_core::THEME_TOKEN_MUTED,
]
);
assert_eq!(
textfx_theme_gradient_css_vars()[1],
dioxus_theme_core::THEME_TOKEN_TEXT
);
}
#[test]
fn dx_textfx_syntax_builds_short_effect_config() {
use crate::dx::DurationDx;
let config = crate::dx::text("headline", "Launch ready")
.scramble()
.dur(420.ms())
.stagger(18.ms())
.split_chars();
assert_eq!(config.id, "headline");
assert_eq!(config.text, "Launch ready");
assert_eq!(config.effect, TextFxEffect::Scramble);
assert_eq!(config.timing.duration_ms, 420);
assert_eq!(config.timing.stagger_ms, 18);
assert_eq!(config.split, TextSplit::Chars);
}
}