diff --git a/crates/fonts-build/src/tests.rs b/crates/fonts-build/src/tests.rs
--- a/crates/fonts-build/src/tests.rs
+++ b/crates/fonts-build/src/tests.rs
@@ -376,7 +376,10 @@ fn for_ui_embeds_cjk_faces_only_as_cut_interface_faces() {
     assert_eq!(warnings(&r), Vec::<String>::new());
     let r = run(Embed::documents(&["Noto Sans TC UI", "Noto Sans TC"]), Some(&dir), Some("1"));
     assert_eq!(r.result.as_ref().unwrap(), &["Noto Sans TC Regular", "Noto Sans TC UI Regular"]);
-    assert!(r.table.contains("fonts/1.ttf"), "{}", r.table);
+    // Generated Rust escapes native separators (and Windows' canonical path prefix).
+    // Match the selected file's complete include expression on every platform.
+    let interface_face = dir.join("fonts/1.ttf").canonicalize().unwrap();
+    assert!(r.table.contains(&format!("include_bytes!({interface_face:?})")), "{}", r.table);
 }
 
 #[test]
diff --git a/crates/ui/README.md b/crates/ui/README.md
--- a/crates/ui/README.md
+++ b/crates/ui/README.md
@@ -55,6 +55,8 @@ remain the responsibility of the existing loader.
 
 ## Icon buttons
 
+Buttons can opt into [hover fades](docs/hover-fades.md) without changing existing styles or interaction.
+
 `buttons::IconButton` accepts a label, size and `IconButtonStyle`, and returns `egui::Response`.
 `ButtonVisuals` describes fill, border and icon tint for normal, hovered, pressed and selected
 states. Selection wins over press and hover. `pressed: None` keeps ordinary hover/normal styling;
diff --git a/crates/ui/docs/hover-fades.md b/crates/ui/docs/hover-fades.md
new file mode 100644
--- /dev/null
+++ b/crates/ui/docs/hover-fades.md
@@ -0,0 +1,19 @@
+# Opt-in icon button hover fades
+
+`IconButton::animated_hover(true)` smoothly blends normal/hover fill, border and icon tint using
+egui's animation clock. It carries the hover-feedback concern from
+[WordCraft #3781](https://github.com/storytold/wordcraft/pull/3781) into the existing shared control.
+It works for ribbons, toolbars and inspectors without importing an application theme.
+
+```rust
+craft_ui::buttons::IconButton::new(localized_label, size, &style)
+    .animated_hover(true)
+    .show(ui, paint_icon);
+```
+
+Existing buttons remain instantaneous by default. Duration follows global `Style::animation_time`;
+the host maps its motion preference to zero to disable animations. Invalid/negative durations
+snap; positive durations are capped at ten seconds. Disabled-state changes snap immediately.
+Selection and an explicit pressed style take precedence over the fade. egui requests repaint
+while animating. Focus, accessibility, hit geometry, clipping and command dispatch are unchanged.
+Stable UI scopes keep each animation attached to its button when labels are translated.
diff --git a/crates/ui/src/buttons/mod.rs b/crates/ui/src/buttons/mod.rs
--- a/crates/ui/src/buttons/mod.rs
+++ b/crates/ui/src/buttons/mod.rs
@@ -51,11 +51,12 @@ pub struct IconButton<'a> {
     selected: bool,
     style: &'a IconButtonStyle,
     single_shape_frame: bool,
+    animated_hover: bool,
 }
 
 impl<'a> IconButton<'a> {
     pub fn new(label: &'a str, size: Vec2, style: &'a IconButtonStyle) -> Self {
-        Self { label, size, selected: false, style, single_shape_frame: false }
+        Self { label, size, selected: false, style, single_shape_frame: false, animated_hover: false }
     }
 
     pub fn selected(mut self, selected: bool) -> Self {
@@ -63,6 +64,16 @@ impl<'a> IconButton<'a> {
         self
     }
 
+    /// Opt into smooth transitions between the normal and hovered visuals. The default is
+    /// instantaneous, preserving existing consumers. Duration comes from egui's global
+    /// `Style::animation_time`; set it to zero for the application's motion-off preference.
+    /// Selected/pressed visuals and disabled-state changes remain immediate. This affects
+    /// painting only: the hit rectangle, focus outline and click response never animate.
+    pub fn animated_hover(mut self, enabled: bool) -> Self {
+        self.animated_hover = enabled;
+        self
+    }
+
     /// Paint fill and border as one egui rectangle instead of separate shapes.
     ///
     /// This preserves the edge tessellation of controls previously painted with `Painter::rect`,
@@ -86,7 +97,23 @@ impl<'a> IconButton<'a> {
             return response;
         }
 
-        let visuals = self.style.visuals(self.selected, response.hovered(), response.is_pointer_button_down_on());
+        let visuals = if self.animated_hover {
+            let duration = ui.ctx().global_style().animation_time;
+            let duration = if ui.is_enabled() && duration.is_finite() { duration.clamp(0.0, 10.0) } else { 0.0 };
+            let hover = ui.ctx().animate_bool_with_time_and_easing(
+                response.id.with("craft_icon_button_hover"),
+                response.hovered(),
+                duration,
+                egui::emath::easing::cubic_out,
+            );
+            if self.selected || (response.is_pointer_button_down_on() && self.style.pressed.is_some()) {
+                self.style.visuals(self.selected, response.hovered(), response.is_pointer_button_down_on())
+            } else {
+                blend(self.style.normal, self.style.hovered, hover)
+            }
+        } else {
+            self.style.visuals(self.selected, response.hovered(), response.is_pointer_button_down_on())
+        };
         let stroke = bounded_stroke(visuals.stroke, rect);
         if self.single_shape_frame {
             ui.painter().rect(rect, self.style.corner_radius, visuals.fill, stroke, StrokeKind::Inside);
@@ -110,6 +137,19 @@ impl<'a> IconButton<'a> {
     }
 }
 
+fn blend(from: ButtonVisuals, to: ButtonVisuals, progress: f32) -> ButtonVisuals {
+    // Sanitize before interpolation: NaN * zero is still NaN at an animation endpoint.
+    let width = |value: f32| if value.is_finite() { value.clamp(0.0, 4096.0) } else { 0.0 };
+    ButtonVisuals {
+        fill: from.fill.lerp_to_gamma(to.fill, progress),
+        icon: from.icon.lerp_to_gamma(to.icon, progress),
+        stroke: Stroke::new(
+            egui::lerp(width(from.stroke.width)..=width(to.stroke.width), progress),
+            from.stroke.color.lerp_to_gamma(to.stroke.color, progress),
+        ),
+    }
+}
+
 fn bounded_stroke(stroke: Stroke, rect: Rect) -> Stroke {
     if stroke.width.is_finite() && stroke.width > 0.0 {
         Stroke::new(stroke.width.min(rect.width().min(rect.height()) * 0.5), stroke.color)
diff --git a/crates/ui/src/buttons/tests.rs b/crates/ui/src/buttons/tests.rs
--- a/crates/ui/src/buttons/tests.rs
+++ b/crates/ui/src/buttons/tests.rs
@@ -22,6 +22,8 @@ struct ButtonTest {
     size: Vec2,
     clicks: usize,
     tint: Option<Color32>,
+    animated_hover: bool,
+    time: f64,
 }
 
 impl ButtonTest {
@@ -29,26 +31,43 @@ impl ButtonTest {
         let ctx = Context::default();
         ctx.enable_accesskit();
         ctx.global_style_mut(|s| s.animation_time = 0.0);
-        Self { ctx, style: style(), enabled: true, selected: false, label: "Inspect layers".into(), size: vec2(32.0, 32.0), clicks: 0, tint: None }
+        Self {
+            ctx,
+            style: style(),
+            enabled: true,
+            selected: false,
+            label: "Inspect layers".into(),
+            size: vec2(32.0, 32.0),
+            clicks: 0,
+            tint: None,
+            animated_hover: false,
+            time: 0.0,
+        }
     }
 
     fn frame(&mut self, events: Vec<Event>) -> (Response, FullOutput) {
         let ctx = self.ctx.clone();
         let mut response = None;
         self.tint = None;
-        let mut output =
-            ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), events, ..Default::default() }, |ui| {
+        self.time += 1.0 / 60.0;
+        let mut output = ctx.run_ui(
+            RawInput { time: Some(self.time), screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), events, ..Default::default() },
+            |ui| {
                 let r = ui
                     .add_enabled_ui(self.enabled, |ui| {
-                        IconButton::new(&self.label, self.size, &self.style).selected(self.selected).show(ui, |ui, rect, tint| {
-                            self.tint = Some(tint);
-                            ui.painter().circle_filled(rect.center(), 3.0, tint);
-                        })
+                        IconButton::new(&self.label, self.size, &self.style).selected(self.selected).animated_hover(self.animated_hover).show(
+                            ui,
+                            |ui, rect, tint| {
+                                self.tint = Some(tint);
+                                ui.painter().circle_filled(rect.center(), 3.0, tint);
+                            },
+                        )
                     })
                     .inner;
                 self.clicks += usize::from(r.clicked());
                 response = Some(r);
-            });
+            },
+        );
         output.textures_delta.clear();
         (response.unwrap(), output)
     }
@@ -59,6 +78,53 @@ impl ButtonTest {
     }
 }
 
+#[test]
+fn opt_in_hover_fades_both_ways_but_press_and_selection_are_immediate() {
+    let mut h = ButtonTest::new();
+    h.animated_hover = true;
+    h.ctx.global_style_mut(|s| s.animation_time = 0.3);
+    let inside = h.settle().rect.center();
+    h.frame(vec![Event::PointerMoved(inside)]);
+    assert_ne!(h.tint, Some(Color32::GREEN), "hover must not jump directly to its target");
+    for _ in 0..24 {
+        h.frame(vec![]);
+    }
+    assert_eq!(h.tint, Some(Color32::GREEN));
+    h.frame(vec![pointer(inside, true)]);
+    assert_eq!(h.tint, Some(Color32::RED));
+    h.selected = true;
+    h.frame(vec![]);
+    assert_eq!(h.tint, Some(Color32::YELLOW));
+    h.selected = false;
+    h.frame(vec![pointer(inside, false), Event::PointerMoved(pos2(300.0, 150.0))]);
+    assert_ne!(h.tint, Some(Color32::WHITE), "leaving should fade instead of jumping");
+    for _ in 0..24 {
+        h.frame(vec![]);
+    }
+    assert_eq!(h.tint, Some(Color32::WHITE));
+}
+
+#[test]
+fn motion_off_and_disabling_snap_even_in_the_middle_of_a_hover() {
+    let mut h = ButtonTest::new();
+    h.animated_hover = true;
+    h.ctx.global_style_mut(|s| s.animation_time = 0.3);
+    let inside = h.settle().rect.center();
+    h.frame(vec![Event::PointerMoved(inside)]);
+    h.enabled = false;
+    h.frame(vec![]);
+    assert_eq!(h.tint, Some(Color32::WHITE));
+    h.enabled = true;
+    // egui's hover hit testing uses the previous pass: register the re-enabled target first.
+    h.frame(vec![]);
+    h.ctx.global_style_mut(|s| s.animation_time = 0.0);
+    let (response, _) = h.frame(vec![Event::PointerMoved(inside)]);
+    assert!(response.hovered());
+    assert_eq!(h.tint, Some(Color32::GREEN));
+    h.frame(vec![Event::PointerMoved(pos2(300.0, 150.0))]);
+    assert_eq!(h.tint, Some(Color32::WHITE));
+}
+
 fn pointer(pos: Pos2, pressed: bool) -> Event {
     Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
 }
