diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs
--- a/crates/ui/src/lib.rs
+++ b/crates/ui/src/lib.rs
@@ -13,6 +13,7 @@ pub mod icons;
 pub mod layout;
 pub mod panes;
 pub mod printing;
+pub mod slider;
 pub mod tabs;
 pub mod text;
 pub mod theme;
diff --git a/crates/ui/src/slider/mod.rs b/crates/ui/src/slider/mod.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui/src/slider/mod.rs
@@ -0,0 +1,374 @@
+//! A slider with a visible, filled track and an optional editable value field.
+//!
+//! egui's built-in slider draws a thin rail in `widgets.inactive.bg_fill`, which several Craft
+//! themes make nearly invisible, so only a lone handle shows and users don't recognise it as a
+//! slider. This one paints a track that is always visible, fills it with the accent up to the
+//! thumb, marks the default value with a tick (double-click the track to return to it), and keeps
+//! an optional value field at a fixed width so nothing next to it moves when the number gains a
+//! digit. Applications supply the colors through [`SliderStyle`]; nothing global is changed.
+//!
+//! Interaction: drag or click the track; the thumb follows the pointer smoothly even when the
+//! value snaps to a [`Slider::step`]. With keyboard focus, Left/Down and Right/Up move one step
+//! (or 1% of the track), Page Up/Down ten, Home/End to the ends. Assistive technology sees a slider
+//! with the given label and value, and its increment/decrement actions work.
+//!
+//! [`ZoomBar`] combines a logarithmic slider with `−`/`+` buttons and a fixed-width percentage, the
+//! status bar zoom control of the Office-style apps.
+
+use std::ops::RangeInclusive;
+
+use egui::{Color32, CornerRadius, EventFilter, Key, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
+
+mod zoom;
+
+pub use zoom::{ZoomBar, ZoomBarLabels, ZoomBarResponse, ZoomBarStyle};
+
+/// A number typed into the value field, with or without its suffix. The suffix itself is taken
+/// off first, so one with a digit in it (`24fps`) is not cut short; then whatever is left of a
+/// half-typed one. A decimal comma counts as a point. Any other text after the number makes the
+/// input invalid (`12xyz` is not 12), so the field keeps its value.
+fn parse_typed(text: &str, suffix: &str) -> Option<f64> {
+    let t = text.trim();
+    let t = if suffix.is_empty() { t } else { t.strip_suffix(suffix).unwrap_or(t) };
+    let number = t.trim_end_matches(|c: char| !c.is_ascii_digit());
+    let rest = t.get(number.len()..).unwrap_or("").trim();
+    let half_typed = !suffix.is_empty() && suffix.starts_with(rest);
+    if !(rest.is_empty() || rest == "." || rest == "," || half_typed) {
+        return None;
+    }
+    number.trim().replace(',', ".").parse().ok()
+}
+
+#[cfg(test)]
+mod tests;
+
+/// Colors for a [`Slider`], built from the application's own tokens.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SliderStyle {
+    /// The unfilled part of the track.
+    pub track: Color32,
+    /// The track from its start to the thumb, usually the accent.
+    pub fill: Color32,
+    /// The thumb's face and border.
+    pub thumb: Color32,
+    pub thumb_stroke: Stroke,
+    /// The dot in the thumb's centre, usually the accent; it grows while hovered or dragged.
+    pub dot: Color32,
+    /// The tick at the default value ([`Slider::mark`]).
+    pub mark: Color32,
+    /// An outline around the thumb while the slider has keyboard focus; `Stroke::NONE` opts out.
+    pub focus_stroke: Stroke,
+}
+
+impl SliderStyle {
+    /// A style derived from egui visuals: the accent from `selection.bg_fill`, the track from the
+    /// noninteractive border, the thumb from the window fill. Applications with design tokens
+    /// should construct the style from those instead.
+    pub fn from_visuals(v: &egui::Visuals) -> Self {
+        Self {
+            track: v.widgets.noninteractive.bg_stroke.color,
+            fill: v.selection.bg_fill,
+            thumb: v.window_fill,
+            thumb_stroke: Stroke::new(1.0, v.widgets.noninteractive.bg_stroke.color),
+            dot: v.selection.bg_fill,
+            mark: v.weak_text_color(),
+            focus_stroke: v.selection.stroke,
+        }
+    }
+}
+
+/// How positions on the track map to values.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum Scale {
+    #[default]
+    Linear,
+    /// Equal distances are equal ratios (zoom: 50% and 200% sit equally far from 100%). The range
+    /// must be positive; otherwise the slider falls back to linear.
+    Logarithmic,
+}
+
+/// A slider; see the module documentation. Build it, then call [`Slider::show`].
+pub struct Slider<'a> {
+    value: &'a mut f64,
+    range: RangeInclusive<f64>,
+    label: &'a str,
+    style: &'a SliderStyle,
+    width: f32,
+    scale: Scale,
+    step: Option<f64>,
+    mark: Option<f64>,
+    field: Option<(usize, &'a str)>,
+    text: Option<(usize, &'a str)>,
+}
+
+const HEIGHT: f32 = 20.0;
+const THUMB_RADIUS: f32 = 7.0;
+
+impl<'a> Slider<'a> {
+    /// `label` names the slider for assistive technology and becomes its tooltip.
+    pub fn new(value: &'a mut f64, range: RangeInclusive<f64>, label: &'a str, style: &'a SliderStyle) -> Self {
+        Self { value, range, label, style, width: 110.0, scale: Scale::Linear, step: None, mark: None, field: None, text: None }
+    }
+
+    /// The track's width in points (default 110).
+    pub fn width(mut self, width: f32) -> Self {
+        self.width = width;
+        self
+    }
+
+    pub fn scale(mut self, scale: Scale) -> Self {
+        self.scale = scale;
+        self
+    }
+
+    /// Snap values to multiples of `step` (zoom: whole percent). Without it the value is continuous.
+    pub fn step(mut self, step: f64) -> Self {
+        self.step = Some(step).filter(|s| s.is_finite() && *s > 0.0);
+        self
+    }
+
+    /// The default value: a tick on the track, and double-clicking the track returns to it.
+    pub fn mark(mut self, value: f64) -> Self {
+        self.mark = Some(value).filter(|v| v.is_finite());
+        self
+    }
+
+    /// Show an editable value field after the track, with `decimals` decimals and a `suffix`
+    /// (`%`, `×`, ` pt`). The field accepts a comma or a point and ignores the suffix when typed.
+    pub fn editable(mut self, decimals: usize, suffix: &'a str) -> Self {
+        self.field = Some((decimals.min(6), suffix));
+        self
+    }
+
+    /// How assistive technology reads the value (`100%` for `(0, "%")`), without a field. An
+    /// [`Slider::editable`] slider reads its value the way the field shows it.
+    pub fn value_text(mut self, decimals: usize, suffix: &'a str) -> Self {
+        self.text = Some((decimals.min(6), suffix));
+        self
+    }
+
+    fn log(&self) -> bool {
+        self.scale == Scale::Logarithmic && *self.range.start() > 0.0 && *self.range.end() > 0.0
+    }
+
+    /// Track fraction (0–1) of `v`.
+    fn fraction(&self, v: f64) -> f64 {
+        let (lo, hi) = (*self.range.start(), *self.range.end());
+        if hi <= lo {
+            return 0.0;
+        }
+        let f = if self.log() { (v.max(lo).ln() - lo.ln()) / (hi.ln() - lo.ln()) } else { (v - lo) / (hi - lo) };
+        if f.is_finite() { f.clamp(0.0, 1.0) } else { 0.0 }
+    }
+
+    /// The value at track fraction `f`, snapped to the step and clamped to the range.
+    fn value_at(&self, f: f64) -> f64 {
+        let (lo, hi) = (*self.range.start(), *self.range.end());
+        let f = f.clamp(0.0, 1.0);
+        let v = if self.log() { (lo.ln() + f * (hi.ln() - lo.ln())).exp() } else { lo + f * (hi - lo) };
+        self.snap(v)
+    }
+
+    fn snap(&self, v: f64) -> f64 {
+        let (lo, hi) = (*self.range.start(), *self.range.end());
+        let v = match self.step {
+            Some(s) => (v / s).round() * s,
+            None => v,
+        };
+        if hi >= lo { v.clamp(lo, hi) } else { lo }
+    }
+
+    fn text(&self, v: f64, decimals: usize, suffix: &str) -> String {
+        format!("{v:.decimals$}{suffix}")
+    }
+
+    /// Paint and handle the slider; the response reports `changed()` when the value changed
+    /// this frame (by pointer, keyboard, accessibility action or the field) and
+    /// `drag_stopped()` when a drag on the track ended.
+    pub fn show(self, ui: &mut Ui) -> Response {
+        // The track and its value field stay on one row, whatever the surrounding layout.
+        if self.field.is_some() && !ui.layout().is_horizontal() {
+            return ui.horizontal(|ui| self.show_row(ui)).inner;
+        }
+        self.show_row(ui)
+    }
+
+    fn show_row(self, ui: &mut Ui) -> Response {
+        if !self.value.is_finite() {
+            *self.value = *self.range.start();
+        }
+        let enabled = ui.is_enabled();
+        let (rect, mut response) = ui.allocate_exact_size(vec2(self.width.max(2.0 * THUMB_RADIUS + 4.0), HEIGHT), Sense::click_and_drag());
+        let (x0, x1) = (rect.left() + THUMB_RADIUS, rect.right() - THUMB_RADIUS);
+        let drag_id = response.id.with("drag-fraction");
+        let mut thumb_fraction = self.fraction(*self.value);
+
+        if enabled {
+            if response.double_clicked()
+                && let Some(mark) = self.mark
+            {
+                let v = self.snap(mark);
+                if v != *self.value {
+                    *self.value = v;
+                    response.mark_changed();
+                }
+                thumb_fraction = self.fraction(v);
+            } else if let Some(p) = response.interact_pointer_pos()
+                && (response.dragged() || response.clicked() || response.drag_started())
+            {
+                let f = f64::from(((p.x - x0) / (x1 - x0).max(1.0)).clamp(0.0, 1.0));
+                ui.data_mut(|d| d.insert_temp(drag_id, f));
+                thumb_fraction = f;
+                let v = self.value_at(f);
+                if v != *self.value {
+                    *self.value = v;
+                    response.mark_changed();
+                }
+            }
+            // The release frame has no drag position of its own: report where the drag ended, so a
+            // caller that applies on release (and re-seeds `value` from its model every frame) gets
+            // the dragged-to value, not the one it started from.
+            if response.drag_stopped()
+                && let Some(f) = ui.data(|d| d.get_temp::<f64>(drag_id))
+            {
+                let v = self.value_at(f);
+                if v != *self.value {
+                    *self.value = v;
+                    response.mark_changed();
+                }
+                // The thumb shows the released value this frame too, not the re-seeded one.
+                thumb_fraction = self.fraction(v);
+            }
+            if response.drag_stopped() || !response.dragged() {
+                ui.data_mut(|d| d.remove::<f64>(drag_id));
+            }
+
+            let (mut down, mut up) = (0i32, 0i32);
+            // Home and End go straight to an end of the range instead of counting steps.
+            let mut jump = None;
+            if response.has_focus() {
+                ui.memory_mut(|m| {
+                    m.set_focus_lock_filter(response.id, EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() })
+                });
+                ui.input(|i| {
+                    down += i.num_presses(Key::ArrowLeft) as i32 + i.num_presses(Key::ArrowDown) as i32 + 10 * i.num_presses(Key::PageDown) as i32;
+                    up += i.num_presses(Key::ArrowRight) as i32 + i.num_presses(Key::ArrowUp) as i32 + 10 * i.num_presses(Key::PageUp) as i32;
+                    if i.key_pressed(Key::Home) {
+                        jump = Some(*self.range.start());
+                    }
+                    if i.key_pressed(Key::End) {
+                        jump = Some(*self.range.end());
+                    }
+                });
+            }
+            ui.input(|i| {
+                use egui::accesskit::Action;
+                down += i.num_accesskit_action_requests(response.id, Action::Decrement) as i32;
+                up += i.num_accesskit_action_requests(response.id, Action::Increment) as i32;
+            });
+            let steps = up - down;
+            if steps != 0 || jump.is_some() {
+                let v = match (jump, self.step) {
+                    // The ends exactly, even off the step grid (`0..=10.3` with a step of 1).
+                    (Some(end), _) => {
+                        if self.range.end() >= self.range.start() {
+                            end
+                        } else {
+                            *self.range.start()
+                        }
+                    }
+                    (None, Some(s)) => self.snap(*self.value + f64::from(steps) * s),
+                    (None, None) => self.value_at(self.fraction(*self.value) + f64::from(steps) * 0.01),
+                };
+                if v != *self.value {
+                    *self.value = v;
+                    response.mark_changed();
+                }
+                thumb_fraction = self.fraction(v);
+            }
+        }
+
+        // Paint. While dragging, the thumb sits under the pointer even if the value snapped.
+        if response.dragged()
+            && let Some(f) = ui.data(|d| d.get_temp::<f64>(drag_id))
+        {
+            thumb_fraction = f;
+        }
+        let s = self.style;
+        let fade = |c: Color32| if enabled { c } else { c.gamma_multiply(0.45) };
+        let y = rect.center().y;
+        let x = x0 + thumb_fraction as f32 * (x1 - x0);
+        let track = Rect::from_min_max(pos2(x0, y - 2.0), pos2(x1, y + 2.0));
+        let painter = ui.painter();
+        painter.rect_filled(track, CornerRadius::same(2), fade(s.track));
+        painter.rect_filled(Rect::from_min_max(track.min, pos2(x, track.max.y)), CornerRadius::same(2), fade(s.fill));
+        if let Some(mark) = self.mark {
+            let mx = x0 + self.fraction(mark) as f32 * (x1 - x0);
+            painter.line_segment([pos2(mx, y - 6.0), pos2(mx, y + 6.0)], Stroke::new(1.0, fade(s.mark)));
+        }
+        let hot = enabled && (response.hovered() || response.dragged());
+        let center = pos2(x, y);
+        painter.circle(center, THUMB_RADIUS, fade(s.thumb), Stroke::new(s.thumb_stroke.width, fade(s.thumb_stroke.color)));
+        painter.circle_filled(center, if hot { 4.5 } else { 3.5 }, fade(s.dot));
+        if response.has_focus() && s.focus_stroke != Stroke::NONE {
+            let r = Rect::from_center_size(center, Vec2::splat(2.0 * THUMB_RADIUS + 4.0));
+            painter.rect_stroke(r, CornerRadius::same(THUMB_RADIUS as u8 + 2), s.focus_stroke, StrokeKind::Outside);
+        }
+
+        let value = *self.value;
+        let label = self.label.to_string();
+        let text = self.field.or(self.text).map(|(d, suffix)| format!("{value:.d$}{suffix}"));
+        response.widget_info(|| egui::WidgetInfo { current_text_value: text.clone(), ..egui::WidgetInfo::slider(enabled, value, &label) });
+        // The range and step, in the value's own units, as egui's slider reports them.
+        ui.ctx().accesskit_node_builder(response.id, |node| {
+            node.set_min_numeric_value(*self.range.start());
+            node.set_max_numeric_value(*self.range.end());
+            if let Some(step) = self.step {
+                node.set_numeric_value_step(step);
+            }
+        });
+        let mut response = response.on_hover_text(self.label);
+
+        if let Some((decimals, suffix)) = self.field {
+            // A fixed width (the wider end of the range), so the field never resizes.
+            let font = egui::TextStyle::Body.resolve(ui.style());
+            let widest = [*self.range.start(), *self.range.end()]
+                .iter()
+                .map(|v| ui.fonts_mut(|f| f.layout_no_wrap(self.text(*v, decimals, suffix), font.clone(), Color32::WHITE).size().x))
+                .fold(0.0f32, f32::max);
+            let suffix_owned = suffix.to_string();
+            let typed_suffix = suffix.trim().to_string();
+            let range = self.range.clone();
+            let mut v = *self.value;
+            let field = ui.add_sized(
+                vec2(widest + 2.0 * ui.spacing().button_padding.x + 4.0, HEIGHT),
+                egui::DragValue::new(&mut v)
+                    .range(range)
+                    .speed(0.0)
+                    .custom_formatter(move |v, _| format!("{v:.decimals$}{suffix_owned}"))
+                    .custom_parser(move |t| parse_typed(t, &typed_suffix)),
+            );
+            // The field edits the same value, so screen readers hear it by the slider's name.
+            let (enabled, name, shown) = (ui.is_enabled(), self.label.to_string(), v);
+            field.widget_info(|| egui::WidgetInfo { label: Some(name.clone()), ..egui::WidgetInfo::drag_value(enabled, shown) });
+            // A typed value that snaps back to the current one (100.4 with a step of 1) is no
+            // change: the field stays in the response (its rect and interaction) but its own
+            // changed flag is not passed on.
+            let mut field = field;
+            let mut applied = false;
+            if field.changed() {
+                let v = self.snap(v);
+                if v != *self.value {
+                    *self.value = v;
+                    response.mark_changed();
+                    applied = true;
+                }
+            }
+            if field.changed() && !applied {
+                field.flags.remove(egui::response::Flags::CHANGED);
+            }
+            response = response.union(field);
+        }
+        response
+    }
+}
diff --git a/crates/ui/src/slider/tests.rs b/crates/ui/src/slider/tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui/src/slider/tests.rs
@@ -0,0 +1,351 @@
+use super::*;
+use egui::{Context, Event, FullOutput, Modifiers, PointerButton, Pos2, RawInput};
+
+struct SliderTest {
+    ctx: Context,
+    style: SliderStyle,
+    value: f64,
+    log: bool,
+    step: Option<f64>,
+    field: bool,
+    rect: Option<Rect>,
+    changes: usize,
+}
+
+impl SliderTest {
+    fn new(value: f64) -> Self {
+        let ctx = Context::default();
+        ctx.enable_accesskit();
+        ctx.global_style_mut(|s| s.animation_time = 0.0);
+        let style = SliderStyle::from_visuals(&egui::Visuals::light());
+        Self { ctx, style, value, log: false, step: None, field: false, rect: None, changes: 0 }
+    }
+
+    fn frame(&mut self, events: Vec<Event>) -> (Response, FullOutput) {
+        let ctx = self.ctx.clone();
+        let mut response = None;
+        let mut output =
+            ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 100.0))), events, ..Default::default() }, |ui| {
+                let mut s = Slider::new(&mut self.value, 10.0..=400.0, "Zoom", &self.style).width(200.0).mark(100.0);
+                if self.log {
+                    s = s.scale(Scale::Logarithmic);
+                }
+                if let Some(step) = self.step {
+                    s = s.step(step);
+                }
+                if self.field {
+                    s = s.editable(0, "%");
+                }
+                let r = s.show(ui);
+                self.changes += usize::from(r.changed());
+                self.rect = Some(r.rect);
+                response = Some(r);
+            });
+        output.textures_delta.clear();
+        (response.unwrap(), output)
+    }
+
+    /// The track's left and right thumb centres, from the first frame's layout.
+    fn track(&mut self) -> (f32, f32, f32) {
+        let (r, _) = self.frame(vec![]);
+        (r.rect.left() + THUMB_RADIUS, r.rect.right() - THUMB_RADIUS, r.rect.center().y)
+    }
+
+    fn click(&mut self, x: f32, y: f32) {
+        let p = pos2(x, y);
+        self.frame(vec![Event::PointerMoved(p), pointer(p, true)]);
+        self.frame(vec![pointer(p, false)]);
+        self.frame(vec![]);
+    }
+}
+
+fn pointer(pos: Pos2, pressed: bool) -> Event {
+    Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
+}
+
+fn key(key: Key) -> Event {
+    Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }
+}
+
+#[test]
+fn clicking_the_track_sets_the_value_linearly_and_logarithmically() {
+    let mut t = SliderTest::new(100.0);
+    let (x0, x1, y) = t.track();
+    t.click(x0 + (x1 - x0) * 0.5, y);
+    assert!((t.value - 205.0).abs() < 1.0, "linear midpoint of 10–400: {}", t.value);
+    assert!(t.changes > 0);
+
+    let mut t = SliderTest::new(100.0);
+    t.log = true;
+    t.step = Some(1.0);
+    let (x0, x1, y) = t.track();
+    t.click(x0 + (x1 - x0) * 0.5, y);
+    assert_eq!(t.value, 63.0, "logarithmic midpoint of 10–400 is √4000 ≈ 63, snapped to whole percent");
+    t.click(x0, y);
+    assert_eq!(t.value, 10.0);
+    t.click(x1 + 50.0, y);
+    assert_eq!(t.value, 10.0, "a click past the track isn't on the slider");
+}
+
+#[test]
+fn double_click_returns_to_the_mark() {
+    let mut t = SliderTest::new(250.0);
+    t.log = true;
+    let (x0, _, y) = t.track();
+    let p = pos2(x0 + 20.0, y);
+    t.frame(vec![Event::PointerMoved(p), pointer(p, true)]);
+    t.frame(vec![pointer(p, false)]);
+    t.frame(vec![pointer(p, true)]);
+    t.frame(vec![pointer(p, false)]);
+    t.frame(vec![]);
+    assert_eq!(t.value, 100.0);
+}
+
+#[test]
+fn keyboard_moves_by_step_page_and_to_the_ends() {
+    let mut t = SliderTest::new(100.0);
+    t.step = Some(1.0);
+    let (r, _) = t.frame(vec![]);
+    t.ctx.memory_mut(|m| m.request_focus(r.id));
+    t.frame(vec![]);
+    t.frame(vec![key(Key::ArrowRight)]);
+    assert_eq!(t.value, 101.0);
+    t.frame(vec![key(Key::PageDown)]);
+    assert_eq!(t.value, 91.0);
+    t.frame(vec![key(Key::End)]);
+    assert_eq!(t.value, 400.0);
+    t.frame(vec![key(Key::Home)]);
+    assert_eq!(t.value, 10.0);
+}
+
+/// Home and End reach the range's ends even when they are off the step grid.
+/// An editable slider's value field is named like the slider, so no input is unnamed.
+#[test]
+fn the_value_field_is_named_like_the_slider() {
+    let mut t = SliderTest::new(100.0);
+    t.field = true;
+    let (_, out) = t.frame(vec![]);
+    let update = out.platform_output.accesskit_update.expect("accesskit is on");
+    let field = update.nodes.iter().map(|(_, n)| n).find(|n| n.role() == egui::accesskit::Role::SpinButton).expect("a value field");
+    assert_eq!(field.label(), Some("Zoom"));
+}
+
+#[test]
+fn home_and_end_reach_off_grid_ends() {
+    let ctx = Context::default();
+    let style = SliderStyle::from_visuals(&egui::Visuals::light());
+    let mut v = 5.0;
+    let run = |events: Vec<Event>, v: &mut f64| -> egui::Id {
+        let mut id = None;
+        let mut out = ctx.run_ui(RawInput { events, ..Default::default() }, |ui| {
+            id = Some(Slider::new(v, 0.3..=10.3, "Level", &style).step(1.0).show(ui).id);
+        });
+        out.textures_delta.clear();
+        id.expect("laid out")
+    };
+    let id = run(vec![], &mut v);
+    ctx.memory_mut(|m| m.request_focus(id));
+    run(vec![], &mut v);
+    run(vec![key(Key::End)], &mut v);
+    assert_eq!(v, 10.3);
+    run(vec![key(Key::Home)], &mut v);
+    assert_eq!(v, 0.3);
+    run(vec![key(Key::ArrowRight)], &mut v);
+    assert_eq!(v, 1.0, "arrows still snap to the step");
+}
+
+#[test]
+fn the_field_keeps_its_width_and_the_track_does_not_move() {
+    let mut t = SliderTest::new(99.0);
+    t.field = true;
+    let (a, _) = t.frame(vec![]);
+    t.value = 100.0;
+    let (b, _) = t.frame(vec![]);
+    assert_eq!(a.rect, b.rect, "track and field together keep their size from 99% to 100%");
+}
+
+#[test]
+fn non_finite_values_and_empty_ranges_are_harmless() {
+    let mut t = SliderTest::new(f64::NAN);
+    t.frame(vec![]);
+    assert_eq!(t.value, 10.0);
+    let style = SliderStyle::from_visuals(&egui::Visuals::dark());
+    let ctx = Context::default();
+    let mut v = 5.0;
+    let mut out = ctx.run_ui(RawInput::default(), |ui| {
+        Slider::new(&mut v, 3.0..=3.0, "Empty", &style).scale(Scale::Logarithmic).show(ui);
+    });
+    out.textures_delta.clear();
+    assert_eq!(v, 5.0, "showing never changes a value nobody touched");
+}
+
+#[test]
+fn assistive_technology_sees_a_labelled_slider() {
+    let mut t = SliderTest::new(100.0);
+    let (_, out) = t.frame(vec![]);
+    let update = out.platform_output.accesskit_update.expect("accesskit is on");
+    let node = update.nodes.iter().map(|(_, n)| n).find(|n| n.role() == egui::accesskit::Role::Slider).expect("a slider node");
+    assert_eq!(node.numeric_value(), Some(100.0));
+    assert_eq!(node.label(), Some("Zoom"));
+    assert_eq!((node.min_numeric_value(), node.max_numeric_value()), (Some(10.0), Some(400.0)), "the range too");
+}
+
+#[test]
+fn assistive_technology_reads_the_zoom_bar_in_percent() {
+    let ctx = Context::default();
+    ctx.enable_accesskit();
+    let mut p = 100.0;
+    let style = ZoomBarStyle {
+        slider: SliderStyle::from_visuals(&egui::Visuals::light()),
+        icon: Color32::GRAY,
+        hover: Color32::LIGHT_GRAY,
+        text: Color32::GRAY,
+        font: 12.0,
+    };
+    let labels = ZoomBarLabels { zoom: "Zoom", zoom_in: "Zoom In", zoom_out: "Zoom Out" };
+    let mut out = ctx.run_ui(RawInput::default(), |ui| {
+        ZoomBar::new(&mut p, 10.0..=400.0, labels, &style).percent_label("Zoom level").show(ui);
+    });
+    out.textures_delta.clear();
+    let update = out.platform_output.accesskit_update.expect("accesskit is on");
+    let nodes: Vec<_> = update.nodes.iter().map(|(_, n)| n).collect();
+    let slider = nodes.iter().find(|n| n.role() == egui::accesskit::Role::Slider).expect("a slider node");
+    assert_eq!((slider.label(), slider.value()), (Some("Zoom"), Some("100%")));
+    assert_eq!((slider.min_numeric_value(), slider.max_numeric_value(), slider.numeric_value_step()), (Some(10.0), Some(400.0), Some(1.0)));
+    let level = nodes.iter().find(|n| n.label() == Some("Zoom level")).expect("the percentage is named");
+    assert_eq!((level.role(), level.value()), (egui::accesskit::Role::Button, Some("100%")));
+    assert!(nodes.iter().all(|n| !n.is_disabled()), "an enabled bar reads as enabled");
+
+    // In a disabled Ui every part, buttons and percentage included, reads as disabled.
+    let mut out = ctx.run_ui(RawInput::default(), |ui| {
+        ui.disable();
+        ZoomBar::new(&mut p, 10.0..=400.0, labels, &style).percent_label("Zoom level").show(ui);
+    });
+    out.textures_delta.clear();
+    let update = out.platform_output.accesskit_update.expect("accesskit is on");
+    for name in ["Zoom", "Zoom In", "Zoom Out", "Zoom level"] {
+        let node = update.nodes.iter().map(|(_, n)| n).find(|n| n.label() == Some(name)).unwrap_or_else(|| panic!("{name}"));
+        assert!(node.is_disabled(), "{name} reads as disabled");
+    }
+}
+
+#[test]
+fn the_field_stays_beside_the_track_in_a_vertical_layout() {
+    let ctx = Context::default();
+    let style = SliderStyle::from_visuals(&egui::Visuals::light());
+    let mut v = 1.0;
+    let mut rect = None;
+    let mut out = ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), ..Default::default() }, |ui| {
+        ui.vertical(|ui| rect = Some(Slider::new(&mut v, 0.5..=2.0, "Speed", &style).editable(2, "×").show(ui).rect));
+    });
+    out.textures_delta.clear();
+    assert!(rect.unwrap().height() <= HEIGHT + 4.0, "one row: {:?}", rect);
+}
+
+fn zoom_frame(ctx: &Context, percent: &mut f64, events: Vec<Event>) -> ZoomBarResponse {
+    let style = ZoomBarStyle {
+        slider: SliderStyle::from_visuals(&egui::Visuals::light()),
+        icon: Color32::GRAY,
+        hover: Color32::LIGHT_GRAY,
+        text: Color32::GRAY,
+        font: 12.0,
+    };
+    let labels = ZoomBarLabels { zoom: "Zoom", zoom_in: "Zoom In", zoom_out: "Zoom Out" };
+    let mut out = None;
+    let mut o = ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 100.0))), events, ..Default::default() }, |ui| {
+        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+            out = Some(ZoomBar::new(percent, 10.0..=500.0, labels, &style).stops(&[10.0, 25.0, 50.0, 75.0, 100.0, 150.0, 200.0, 500.0]).show(ui));
+        });
+    });
+    o.textures_delta.clear();
+    out.unwrap()
+}
+
+#[test]
+fn zoom_bar_keeps_its_size_and_steps_through_its_stops() {
+    let ctx = Context::default();
+    let mut p = 99.0;
+    let a = zoom_frame(&ctx, &mut p, vec![]).response.rect;
+    p = 100.0;
+    let b = zoom_frame(&ctx, &mut p, vec![]).response.rect;
+    assert_eq!(a, b, "same block from 99% to 100%, in a right-to-left layout too");
+    // The `+` button is the second 18 pt square from the right edge of the slider; find it by
+    // clicking left to right across the block and watching the value.
+    let y = b.center().y;
+    let plus_x = b.left() + 18.0 + 110.0 + 9.0;
+    for e in [vec![Event::PointerMoved(pos2(plus_x, y)), pointer(pos2(plus_x, y), true)], vec![pointer(pos2(plus_x, y), false)]] {
+        zoom_frame(&ctx, &mut p, e);
+    }
+    assert_eq!(p, 150.0, "+ goes to the next stop");
+    let minus_x = b.left() + 9.0;
+    for e in [vec![Event::PointerMoved(pos2(minus_x, y)), pointer(pos2(minus_x, y), true)], vec![pointer(pos2(minus_x, y), false)]] {
+        zoom_frame(&ctx, &mut p, e);
+    }
+    assert_eq!(p, 100.0, "- goes to the previous stop");
+    // From between stops, + and - go to the nearest stop on that side, not past it.
+    for (from, want) in [(99.7, 100.0), (100.3, 100.0)] {
+        p = from;
+        let x = if want > from { plus_x } else { minus_x };
+        for e in [vec![Event::PointerMoved(pos2(x, y)), pointer(pos2(x, y), true)], vec![pointer(pos2(x, y), false)]] {
+            zoom_frame(&ctx, &mut p, e);
+        }
+        assert_eq!(p, want, "from {from}");
+    }
+}
+
+#[test]
+fn typed_values_drop_the_suffix_even_when_it_holds_a_digit() {
+    assert_eq!(parse_typed("120%", "%"), Some(120.0));
+    assert_eq!(parse_typed(" 12,5 pt ", "pt"), Some(12.5));
+    assert_eq!(parse_typed("24fps", "fps"), Some(24.0));
+    assert_eq!(parse_typed("30 f/1s", "f/1s"), Some(30.0), "the suffix's own digit is not read as part of the number");
+    assert_eq!(parse_typed("24 fp", "fps"), Some(24.0), "a half-typed suffix");
+    assert_eq!(parse_typed("fast", "%"), None);
+    // Anything else after the number is not a value; a trailing separator still is.
+    assert_eq!(parse_typed("12xyz", "%"), None);
+    assert_eq!(parse_typed("12 pt", "%"), None);
+    assert_eq!(parse_typed("12%x", "%"), None);
+    assert_eq!(parse_typed("12.", "%"), Some(12.0));
+    assert_eq!(parse_typed("12,", "%"), Some(12.0));
+    assert_eq!(parse_typed("12", ""), Some(12.0));
+    assert_eq!(parse_typed("12x", ""), None);
+}
+
+#[test]
+fn a_drag_reports_where_it_ended_to_a_caller_that_applies_on_release() {
+    // The caller keeps its own value and re-seeds the slider with it every frame, applying only
+    // when the drag ends: the release frame must hand back the dragged-to value.
+    let ctx = Context::default();
+    let style = SliderStyle::from_visuals(&egui::Visuals::light());
+    let mut model = 100.0;
+    let mut applied = Vec::new();
+    let frame = |events: Vec<Event>, model: &mut f64, applied: &mut Vec<f64>| -> Rect {
+        let mut rect = Rect::NOTHING;
+        let mut out =
+            ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 100.0))), events, ..Default::default() }, |ui| {
+                let mut v = *model;
+                let r = Slider::new(&mut v, 10.0..=400.0, "Size", &style).width(200.0).show(ui);
+                if r.drag_stopped() || (r.changed() && !r.dragged()) {
+                    applied.push(v);
+                    *model = v;
+                }
+                rect = r.rect;
+            });
+        out.textures_delta.clear();
+        rect
+    };
+    let rect = frame(vec![], &mut model, &mut applied);
+    let (x0, x1, y) = (rect.left() + THUMB_RADIUS, rect.right() - THUMB_RADIUS, rect.center().y);
+    let from = pos2(x0 + 2.0, y);
+    let to = pos2(x1, y);
+    frame(vec![Event::PointerMoved(from), pointer(from, true)], &mut model, &mut applied);
+    for k in 1..=6 {
+        let p = from.lerp(to, k as f32 / 6.0);
+        frame(vec![Event::PointerMoved(p)], &mut model, &mut applied);
+    }
+    assert!(applied.is_empty(), "nothing applies while the slider is held: {applied:?}");
+    frame(vec![pointer(to, false)], &mut model, &mut applied);
+    frame(vec![], &mut model, &mut applied);
+    assert_eq!(applied.len(), 1, "{applied:?}");
+    assert!((model - 400.0).abs() < 1e-6, "the release applies the end of the range, got {model}");
+}
diff --git a/crates/ui/src/slider/zoom.rs b/crates/ui/src/slider/zoom.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui/src/slider/zoom.rs
@@ -0,0 +1,166 @@
+//! The status bar zoom control of the Office-style Craft apps: `−`, a logarithmic [`Slider`], `+`
+//! and the percentage, laid out as one fixed-size block so nothing moves when the number gains a
+//! digit, whatever direction the surrounding layout runs.
+
+use egui::{Align2, Color32, CornerRadius, FontId, Layout, Response, Sense, Stroke, Ui, pos2, vec2};
+
+use super::{Scale, Slider, SliderStyle};
+
+/// Colours of a [`ZoomBar`]: its slider's plus the `−`/`+` glyphs, their hover fill and the text.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ZoomBarStyle {
+    pub slider: SliderStyle,
+    pub icon: Color32,
+    pub hover: Color32,
+    pub text: Color32,
+    pub font: f32,
+}
+
+/// The accessible names (and tooltips) of the parts, in the interface language.
+#[derive(Clone, Copy, Debug)]
+pub struct ZoomBarLabels<'a> {
+    pub zoom: &'a str,
+    pub zoom_in: &'a str,
+    pub zoom_out: &'a str,
+}
+
+/// What happened this frame.
+#[derive(Debug)]
+pub struct ZoomBarResponse {
+    /// The percentage changed (slider, keyboard or a button); apply it.
+    pub changed: bool,
+    /// The percentage text was clicked: Office apps open their Zoom dialog.
+    pub percent_clicked: bool,
+    /// The whole block.
+    pub response: Response,
+}
+
+/// A zoom bar; see the module documentation.
+pub struct ZoomBar<'a> {
+    percent: &'a mut f64,
+    range: std::ops::RangeInclusive<f64>,
+    labels: ZoomBarLabels<'a>,
+    style: &'a ZoomBarStyle,
+    stops: &'a [f64],
+    slider_width: f32,
+    percent_label: Option<&'a str>,
+}
+
+const BUTTON: f32 = 18.0;
+/// How close to a stop counts as on it, so `+` from 99.7% goes to 100%, not past it, while
+/// rounding noise at a stop (100.000001) still moves on to the next one.
+const EPS: f64 = 1e-6;
+const GAP: f32 = 4.0;
+/// Space between `−`/`+` and the slider: none, since the slider already keeps its thumb inset
+/// from its ends, so the glyphs sit right at the track.
+const TIGHT: f32 = 0.0;
+
+impl<'a> ZoomBar<'a> {
+    pub fn new(percent: &'a mut f64, range: std::ops::RangeInclusive<f64>, labels: ZoomBarLabels<'a>, style: &'a ZoomBarStyle) -> Self {
+        Self { percent, range, labels, style, stops: &[], slider_width: 110.0, percent_label: None }
+    }
+
+    /// The values `−` and `+` step through (sorted, in percent). Without stops they step to the
+    /// previous or next multiple of 10.
+    pub fn stops(mut self, stops: &'a [f64]) -> Self {
+        self.stops = stops;
+        self
+    }
+
+    /// The percentage's accessible name and tooltip (`Zoom level`); without it the percentage is
+    /// named like the slider. Its value is the percentage either way.
+    pub fn percent_label(mut self, label: &'a str) -> Self {
+        self.percent_label = Some(label);
+        self
+    }
+
+    pub fn slider_width(mut self, width: f32) -> Self {
+        self.slider_width = width;
+        self
+    }
+
+    fn step(&self, up: bool) -> f64 {
+        let v = *self.percent;
+        let (lo, hi) = (*self.range.start(), *self.range.end());
+        let next = if self.stops.is_empty() {
+            if up { (v / 10.0).floor() * 10.0 + 10.0 } else { (v / 10.0).ceil() * 10.0 - 10.0 }
+        } else if up {
+            self.stops.iter().copied().find(|s| *s > v + EPS).unwrap_or(hi)
+        } else {
+            self.stops.iter().rev().copied().find(|s| *s < v - EPS).unwrap_or(lo)
+        };
+        next.clamp(lo, hi)
+    }
+
+    /// The block's size: both buttons, the slider and the widest percentage (`500%` for a range
+    /// ending at 500), with the gaps between them.
+    fn label_width(&self, ui: &Ui) -> f32 {
+        let font = FontId::proportional(self.style.font);
+        let widest = format!("{:.0}%", self.range.end().abs().max(self.range.start().abs()));
+        ui.fonts_mut(|f| f.layout_no_wrap(widest, font, Color32::WHITE).size().x) + 1.0
+    }
+
+    pub fn show(self, ui: &mut Ui) -> ZoomBarResponse {
+        let label_w = self.label_width(ui);
+        let size = vec2(BUTTON + TIGHT + self.slider_width + TIGHT + BUTTON + 2.0 * GAP + label_w, BUTTON.max(20.0));
+        let (down_to, up_to) = (self.step(false), self.step(true));
+        let mut changed = false;
+        let mut percent_clicked = false;
+        let style = self.style;
+        let labels = self.labels;
+        let range = self.range.clone();
+        let slider_width = self.slider_width;
+        let percent = self.percent;
+        let percent_name = self.percent_label.unwrap_or(labels.zoom);
+        let inner = ui.allocate_ui_with_layout(size, Layout::left_to_right(egui::Align::Center), |ui| {
+            ui.spacing_mut().item_spacing.x = TIGHT;
+            if glyph_button(ui, false, labels.zoom_out, style) {
+                *percent = down_to;
+                changed = true;
+            }
+            let r = Slider::new(percent, range, labels.zoom, &style.slider)
+                .width(slider_width)
+                .scale(Scale::Logarithmic)
+                .step(1.0)
+                .mark(100.0)
+                .value_text(0, "%")
+                .show(ui);
+            changed |= r.changed();
+            if glyph_button(ui, true, labels.zoom_in, style) {
+                *percent = up_to;
+                changed = true;
+            }
+            ui.add_space(2.0 * GAP);
+            let (rect, resp) = ui.allocate_exact_size(vec2(label_w, size.y), Sense::click());
+            // Faded like the glyphs when the bar is disabled.
+            let text = if ui.is_enabled() { style.text } else { style.text.gamma_multiply(0.45) };
+            ui.painter().text(rect.right_center(), Align2::RIGHT_CENTER, format!("{:.0}%", *percent), FontId::proportional(style.font), text);
+            let shown = format!("{:.0}%", *percent);
+            let enabled = ui.is_enabled();
+            resp.widget_info(|| egui::WidgetInfo {
+                current_text_value: Some(shown.clone()),
+                ..egui::WidgetInfo::labeled(egui::Role::Button, enabled, percent_name)
+            });
+            percent_clicked = resp.on_hover_text(percent_name).clicked();
+        });
+        ZoomBarResponse { changed, percent_clicked, response: inner.response }
+    }
+}
+
+/// A thin `−` or `+` button with a hover fill; returns whether it was clicked.
+fn glyph_button(ui: &mut Ui, plus: bool, label: &str, style: &ZoomBarStyle) -> bool {
+    let (rect, resp) = ui.allocate_exact_size(vec2(BUTTON, BUTTON), Sense::click());
+    if resp.hovered() || resp.has_focus() {
+        ui.painter().rect_filled(rect, CornerRadius::same(3), style.hover);
+    }
+    let c = rect.center();
+    let stroke = Stroke::new(1.3, if ui.is_enabled() { style.icon } else { style.icon.gamma_multiply(0.45) });
+    let half = 5.0;
+    ui.painter().line_segment([pos2(c.x - half, c.y), pos2(c.x + half, c.y)], stroke);
+    if plus {
+        ui.painter().line_segment([pos2(c.x, c.y - half), pos2(c.x, c.y + half)], stroke);
+    }
+    let enabled = ui.is_enabled();
+    resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label));
+    resp.on_hover_text(label).clicked()
+}
