Back to Blog

Build Image Filters with egui Sliders | Rust GUI Tutorial #14

Sandy LaneSandy Lane

Video: Build Image Filters with egui Sliders | Rust GUI Tutorial #14 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

egui::Slider::new(&mut self.value, range) — continuous numeric input with a draggable handle.

When the user needs to set a numeric value within a range — brightness, volume, opacity, font size — radio buttons and dropdowns are wrong. You want a slider: a draggable handle along a track, with smooth values and immediate feedback. egui's Slider does exactly this in one line.

What we are building

An image-filter control panel. Four sliders for Brightness, Contrast, Saturation, Blur — each with a range and a units suffix. A "Reset All" button that puts everything back to defaults. A central panel with a colored preview rectangle whose color is computed from the current slider values.

The script (slider section)

ui.label("Brightness:");
ui.add(
  egui::Slider::new(&mut self.brightness, -100.0..=100.0)
    .suffix(" %"),
);

ui.label("Contrast:");
ui.add(
  egui::Slider::new(&mut self.contrast, 0.0..=200.0)
    .suffix(" %"),
);

ui.label("Saturation:");
ui.add(
  egui::Slider::new(&mut self.saturation, 0.0..=200.0)
    .suffix(" %"),
);

ui.label("Blur:");
ui.add(
  egui::Slider::new(&mut self.blur, 0.0..=20.0)
    .suffix(" px"),
);

Each slider is Slider::new(&mut value, range).suffix(" %") wrapped in ui.add(). Drag the handle, the bound f32 updates, the next frame's central panel re-renders with the new value.

Slider::new(&mut value, range)

egui::Slider::new(&mut self.brightness, -100.0..=100.0)

Two arguments:

  • A mutable reference to the value the slider edits. Same bound-widget pattern as text edits and checkboxes.
  • A range as Rust RangeInclusive<f32> (the ..= syntax). The slider clamps the value to this range — drag past the end and it pegs at the bound.

The slider works for any numeric type that implements egui's Numeric trait — f32, f64, i32, u32, etc. The range type matches the value type.

Builder methods worth knowing

  • .suffix(" %") — text appended after the value. Units of measure.
  • .prefix("$") — text before the value.
  • .text("Brightness") — label to the right of the slider (alternative to the separate ui.label(...)).
  • .step_by(5.0) — snap to multiples of 5. Useful for integer-feeling controls.
  • .logarithmic(true) — non-linear distribution; useful for ranges like 1Hz to 20kHz where a linear slider would be unusable.
  • .smart_aim(true) — egui's default behaviour that snaps to "round" values when the user drags slowly.
  • .clamp_to_range(true) — also default; turn off if you want to allow values outside the range when typed numerically.
  • .show_value(false) — hide the numeric readout next to the handle.

For most use cases, Slider::new plus .suffix is enough.

Computing the preview color

fn preview_color(&self) -> egui::Color32 {
  let base_r = 100.0_f32;
  let base_g = 149.0_f32;
  let base_b = 237.0_f32;

  let r = base_r + self.brightness;
  // ... contrast ...
  // ... saturation ...
  // ... clamp ...

  egui::Color32::from_rgb(r, g, b)
}

A simple piece of color math turns the four slider values into a single output color. The math is illustrative — real image filters apply per-pixel — but it is enough to make the preview rectangle feel responsive.

The point isn't the math; it is that state in, frame out is the whole render path. Move a slider, the next frame's preview color changes. No subscription, no recomputation triggers; it is just code that runs every frame.

Painting the preview

let color = self.preview_color();
let size = egui::vec2(400.0, 300.0);
let (rect, _response) = ui.allocate_exact_size(
  size,
  egui::Sense::hover(),
);
ui.painter().rect_filled(rect, 0.0, color);

Same custom-paint pattern as Episode 12's color swatches. Reserve a rectangle of the right size, paint into it. The rectangle's fill color is computed from the slider state.

Reset All

if ui.button("Reset All").clicked() {
  *self = MyApp::default();
}

A single line resets every slider to its default. *self = MyApp::default() replaces the entire struct. Because all four sliders bind to fields of MyApp, replacing the struct resets all four at once.

This is a Rust pattern that pairs well with egui: when "reset" means "back to defaults," and Default is what you want, replacing *self is concise and correct.

Running it

cargo run. The left panel has four sliders. Drag any of them — the preview rectangle updates immediately. The status line at the bottom shows the current values.

Drag Brightness all the way left — preview goes black. All the way right — bright. Saturation to 0 — preview becomes grayscale. Click "Reset All" — every slider snaps back.

When to use a slider vs. other widgets

  • Slider — continuous numeric value within a known range. Brightness, opacity, volume, time, percentages.
  • Drag value (Episode 20) — numeric input without a visible track. Better for unbounded or large-range values.
  • Spinner / number input — when typing the exact value matters more than dragging.
  • Radio buttons / combobox — for discrete choices, not continuous values.

The rule: if dragging feels right, use a slider. If typing feels right, use a drag value or text input.

Common mistakes

Forgetting ..= (inclusive range). 0.0..100.0 is exclusive — the slider can never quite reach 100. Use 0.0..=100.0.

Using mismatched types. Slider::new(&mut self.x, 0..100) where self.x: f32 — the range needs to match the value type.

No suffix for percentages. A slider showing "75" is ambiguous; "75 %" is clear. Always provide units.

Slider for a value that should be discrete. If the variable can only be 1, 2, or 3, use radio buttons. Sliders set up a continuous expectation.

What's next

Next episode: progress bar. A simulated download manager with egui::ProgressBar, animated progress, and a status line that updates as the download "completes."

Recap

egui::Slider::new(&mut value, range).suffix(" %") for a continuous numeric input. Drag the handle, the bound field updates, the next frame's render reflects it. Pair with ui.painter() for live previews. Use .logarithmic(true) for wide-range values, .step_by for snapping.

Next episode: progress bar. See you in the next one.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.