diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs
--- a/crates/export/src/lib.rs
+++ b/crates/export/src/lib.rs
@@ -375,7 +375,7 @@ async fn gif_export(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_
         let end = (i + batch).min(st.total);
         let frames: Vec<gif::Frame<'static>> = job
             .frames(comp, (i..end).collect(), |_, img| {
-                let mut px = job.pixels(&img, comp, job.output.channels, w, h);
+                let mut px = job.pixels_with(&img, comp, job.output.channels, w, h, false);
                 gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10)
             })
             .await;
diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs
--- a/crates/export/src/pipeline.rs
+++ b/crates/export/src/pipeline.rs
@@ -4,10 +4,8 @@
 
 use std::sync::Mutex;
 
-use effectcraft_project::render_queue::{
-    AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path,
-};
-use effectcraft_project::{Comp, Project};
+use effectcraft_project::render_queue::{AlphaMode, Channels, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path};
+use effectcraft_project::{BitDepth, Comp, Project};
 use effectcraft_raster::Image;
 use effectcraft_render::{Backend, LayerCache, RenderOpts, Renderer};
 use effectcraft_time::Tick;
@@ -166,6 +164,9 @@ impl<'a> Cx<'a> {
     pub fn new(job: &'a Job<'a>) -> Cx<'a> {
         let mut project = job.project.clone();
         apply_overrides(&mut project, job);
+        // Render Settings ▸ Color Depth overrides the project's depth for the whole render (every
+        // layer and effect quantises at it), not just the finished frame (#2022).
+        project.settings.bit_depth = job.settings.color_depth.resolve(project.settings.bit_depth);
         let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new()));
         let cache = LayerCache::default();
         // With deferred readbacks, buffers computed from a missed pass's placeholders are not kept.
@@ -289,14 +290,6 @@ impl<'a> Cx<'a> {
                 if second == first { a } else { interleave(&a, &self.render_at(second).await, upper_first) }
             }
         };
-        let mut img = img;
-        // Color Depth: renders run at the project's depth; another depth quantises the frame.
-        let depth = self.job.settings.color_depth;
-        if depth != ColorDepth::Current
-            && let Some(levels) = depth.resolve(self.project.settings.bit_depth).levels()
-        {
-            img.quantize(levels);
-        }
         let img = self.crop_resize(img);
         if let Some(ft) = &self.frame_times {
             ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64()));
@@ -325,12 +318,24 @@ impl<'a> Cx<'a> {
 
     /// 8-bit RGBA of the requested channels cropped/padded to `w`×`h` (codec rounding): RGB over
     /// the comp background (opaque), RGB + Alpha straight or premultiplied, or the alpha matte.
+    /// When the render runs above 8 bpc, the colour is dithered on its way to 8 bits, as After
+    /// Effects dithers colours converted down to 8 bpc for rendering and export, so gradients
+    /// rendered at 16 or 32 bpc don't band (#2022). An 8 bpc render has no finer values to
+    /// dither, so its output stays byte for byte what it was.
     pub fn pixels(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec<u8> {
+        let dither = self.project.settings.bit_depth != BitDepth::Bpc8;
+        self.pixels_with(img, comp, channels, w, h, dither)
+    }
+
+    /// [`Cx::pixels`], dithering the colour or not (a GIF palette gains nothing from dither noise).
+    pub fn pixels_with(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, dither: bool) -> Vec<u8> {
         let px = match channels {
+            Channels::Rgb if dither => img.to_rgba8_over_dithered(comp.background),
             Channels::Rgb => img.to_rgba8_over(comp.background),
-            Channels::Rgba => match self.job.output.alpha_mode {
-                AlphaMode::Straight => img.to_rgba8(),
-                AlphaMode::Premultiplied => premultiplied8(img),
+            Channels::Rgba => match (self.job.output.alpha_mode, dither) {
+                (AlphaMode::Straight, true) => img.to_rgba8_dithered(),
+                (AlphaMode::Straight, false) => img.to_rgba8(),
+                (AlphaMode::Premultiplied, _) => premultiplied8(img, dither),
             },
             Channels::Alpha => img
                 .data
@@ -491,15 +496,20 @@ fn bicubic(src: &Image, w: u32, h: u32) -> Image {
     out
 }
 
-/// Premultiplied 8-bit RGBA (colour matted with black).
-fn premultiplied8(img: &Image) -> Vec<u8> {
-    img.data
-        .iter()
-        .flat_map(|p| {
-            let a = p[3].clamp(0.0, 1.0);
-            [0, 1, 2].map(|c| (p[c].clamp(0.0, a) * 255.0 + 0.5) as u8).into_iter().chain([(a * 255.0 + 0.5) as u8])
-        })
-        .collect()
+/// Premultiplied 8-bit RGBA (colour matted with black), the colour dithered when `dither`.
+fn premultiplied8(img: &Image, dither: bool) -> Vec<u8> {
+    let w = img.width.max(1) as usize;
+    let mut out = Vec::with_capacity(img.data.len() * 4);
+    for (i, p) in img.data.iter().enumerate() {
+        let a = p[3].clamp(0.0, 1.0);
+        let (x, y) = ((i % w) as u32, (i / w) as u32);
+        for c in 0..3 {
+            let v = p[c].clamp(0.0, a);
+            out.push(if dither { effectcraft_raster::dither_u8(v, x, y) } else { (v * 255.0 + 0.5) as u8 });
+        }
+        out.push((a * 255.0 + 0.5) as u8);
+    }
+    out
 }
 
 #[cfg(test)]
diff --git a/crates/export/tests/it/banding.rs b/crates/export/tests/it/banding.rs
new file mode 100644
--- /dev/null
+++ b/crates/export/tests/it/banding.rs
@@ -0,0 +1,156 @@
+//! Gradients exported at 16 bpc (or with a 16 bpc Color Depth override) don't band (#2022): the
+//! float frame is dithered on its way to 8 bits, and the Render Settings' Color Depth reaches the
+//! render itself.
+
+use std::sync::{Arc, Mutex};
+
+use effectcraft_color::Label;
+use effectcraft_export::render_queue::*;
+use effectcraft_export::{Job, JobOptions, Sink, export};
+use effectcraft_keyframe::Value;
+use effectcraft_project::build::{self, Ids};
+use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, LayerSource, Project, Solid};
+use effectcraft_render::NoFootage;
+use effectcraft_time::{FrameRate, Tick};
+
+const W: u32 = 1024;
+const H: u32 = 16;
+/// The ramp runs from this grey to `HI` across the comp: about 25 eight-bit codes in 1024 px,
+/// so plain rounding makes bands about 40 px wide.
+const LO: f64 = 0.20;
+const HI: f64 = 0.30;
+/// Width of the column blocks whose mean is compared with the ideal ramp.
+const WINDOW: u32 = 8;
+
+/// A grey horizontal ramp over a comp of the given project depth.
+fn ramp_project(depth: BitDepth) -> (Project, ItemId) {
+    let mut p = Project::default();
+    p.settings.bit_depth = depth;
+    let mut comp = Comp::new(W, H, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0));
+    comp.background = [0.0, 0.0, 0.0];
+    let sid = p.add_item("Ramp", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: W, height: H, pixel_aspect: 1.0 }));
+    let mut layer = build::layer(&mut p, &comp, "Ramp", LayerSource::Solid { item: sid }, (W, H), None);
+    let spec = effectcraft_effects::find("ec.generate.gradientramp").expect("gradient ramp effect");
+    let mut next = p.next_id;
+    let mut group = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]);
+    p.next_id = next;
+    for (k, v) in [
+        ("start", Value::Vec2([0.0, 0.0])),
+        ("end", Value::Vec2([W as f64, 0.0])),
+        ("startColor", Value::Color([LO, LO, LO, 1.0])),
+        ("endColor", Value::Color([HI, HI, HI, 1.0])),
+    ] {
+        group.get_mut(k).expect("ramp parameter").value = v;
+    }
+    layer.props.sub_mut("effects").expect("effects group").children.push(group.into());
+    comp.layers.push(layer);
+    let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
+    (p, cid)
+}
+
+/// Export frame 0 as PNG with the Render Settings' Color Depth `depth` and return the grey of
+/// every row.
+fn export_rows(p: &Project, cid: ItemId, depth: ColorDepth) -> Vec<Vec<u8>> {
+    let files: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();
+    let f = files.clone();
+    let sink: Box<Sink> = Box::new(move |path: &str, data: Vec<u8>| f.lock().unwrap().push((path.to_string(), data)));
+    let settings =
+        RenderSettings { time_span: TimeSpan::Custom { start: Tick::ZERO, end: Tick::from_seconds_f64(0.04) }, color_depth: depth, ..Default::default() };
+    let om = OutputModule::for_format(OutputFormat::PngSequence);
+    let job = Job {
+        project: p,
+        footage: &NoFootage,
+        expr: None,
+        accel: None,
+        comp: cid,
+        settings: &settings,
+        output: &om,
+        path: "/banding/f_[#####].png",
+        sink: Some(&*sink),
+        options: JobOptions::default(),
+        nested_switches: true,
+    };
+    export(&job, &mut |_| true).expect("export");
+    let files = files.lock().unwrap();
+    let (_, png) = files.iter().find(|(path, _)| path.ends_with(".png")).expect("a PNG");
+    let img = image::load_from_memory(png).expect("png").to_rgba8();
+    (0..H).map(|y| (0..W).map(|x| img.get_pixel(x, y).0[1]).collect()).collect()
+}
+
+/// The ideal ramp at column `x`, in 8-bit codes.
+fn ideal(x: f64) -> f64 {
+    255.0 * (LO + (HI - LO) * (x + 0.5) / W as f64)
+}
+
+/// Longest run of equal neighbours in the row: a band's width.
+fn longest_run(row: &[u8]) -> usize {
+    let (mut best, mut run) = (1, 1);
+    for w in row.windows(2) {
+        run = if w[0] == w[1] { run + 1 } else { 1 };
+        best = best.max(run);
+    }
+    best
+}
+
+/// Horizontal neighbours with different values, over all rows: ~25 per row when every band is a
+/// flat step, hundreds when the steps are dithered.
+fn transitions(rows: &[Vec<u8>]) -> usize {
+    rows.iter().map(|r| r.windows(2).filter(|w| w[0] != w[1]).count()).sum()
+}
+
+/// Largest difference, in 8-bit codes, between the mean of a `WINDOW`-column block over all rows
+/// and the ideal ramp's mean over it. Plain rounding leaves up to ~0.4 codes of it beside a step
+/// edge; dither averages it away (its noise on a mean of `WINDOW * H` samples is under 0.05).
+fn worst_window_error(rows: &[Vec<u8>]) -> f64 {
+    (0..W / WINDOW)
+        .map(|i| {
+            let (x0, x1) = ((i * WINDOW) as usize, ((i + 1) * WINDOW) as usize);
+            let sum: f64 = rows.iter().flat_map(|r| &r[x0..x1]).map(|&v| v as f64).sum();
+            let mean = sum / (rows.len() * (x1 - x0)) as f64;
+            (mean - ideal((x0 + x1) as f64 / 2.0 - 1.0)).abs()
+        })
+        .fold(0.0, f64::max)
+}
+
+#[test]
+fn a_16_bpc_gradient_export_is_dithered() {
+    let (p, cid) = ramp_project(BitDepth::Bpc16);
+    let rows = export_rows(&p, cid, ColorDepth::Current);
+    let (steps, err) = (transitions(&rows), worst_window_error(&rows));
+    eprintln!("16 bpc project: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes");
+    // Undithered there are ~25 flat steps per row (400 transitions) and the block means are off
+    // by up to ~0.4 codes.
+    assert!(steps > 2000, "the steps survive: {steps} transitions");
+    assert!(err < 0.25, "the local mean is off by {err} codes");
+    // Dither never moves a value a whole code from the ideal ramp.
+    for row in &rows {
+        for (x, &v) in row.iter().enumerate() {
+            let want = ideal(x as f64);
+            assert!((v as f64 - want).abs() < 1.05, "x {x}: {v} vs {want}");
+        }
+    }
+}
+
+#[test]
+fn the_export_is_the_same_every_time() {
+    let (p, cid) = ramp_project(BitDepth::Bpc16);
+    assert_eq!(export_rows(&p, cid, ColorDepth::Current), export_rows(&p, cid, ColorDepth::Current));
+}
+
+#[test]
+fn the_render_color_depth_reaches_the_render() {
+    // An 8 bpc project renders every layer at 8 bits: flat steps, nothing for the output to dither.
+    let (p, cid) = ramp_project(BitDepth::Bpc8);
+    let banded = export_rows(&p, cid, ColorDepth::Current);
+    assert!(longest_run(&banded[0]) >= 30, "an 8 bpc render is quantised before export");
+    assert!(transitions(&banded) < 16 * 30);
+    // The Render Settings' 16 bpc depth overrides the project's: the render keeps its precision
+    // until the final conversion, which dithers.
+    let deep = export_rows(&p, cid, ColorDepth::Bpc16);
+    let (steps, err) = (transitions(&deep), worst_window_error(&deep));
+    eprintln!("8 bpc project, 16 bpc render: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes");
+    assert!(steps > 2000 && err < 0.25, "a 16 bpc render still bands: {steps} transitions, error {err}");
+    // And 8 bpc on a 16 bpc project renders at 8 bits.
+    let (p16, cid16) = ramp_project(BitDepth::Bpc16);
+    assert_eq!(export_rows(&p16, cid16, ColorDepth::Bpc8), banded);
+}
diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs
--- a/crates/export/tests/it/main.rs
+++ b/crates/export/tests/it/main.rs
@@ -1,6 +1,7 @@
 //! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
 
 mod audio_sample_positions;
+mod banding;
 mod hevc_av1_roundtrip;
 #[cfg(not(target_arch = "wasm32"))]
 mod mov_audio_sample_positions;
diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs
--- a/crates/raster/src/lib.rs
+++ b/crates/raster/src/lib.rs
@@ -221,7 +221,7 @@ impl Image {
     }
     /// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored).
     pub fn to_rgba8(&self) -> Vec<u8> {
-        self.map_rgba8(|o, p| {
+        self.map_rgba8(|o, p, _, _| {
             let a = p[3].clamp(0.0, 1.0);
             let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 };
             for c in 0..3 {
@@ -230,9 +230,20 @@ impl Image {
             o[3] = (a * 255.0 + 0.5) as u8;
         })
     }
+    /// [`Image::to_rgba8`] with the colour dithered ([`dither_u8`]); alpha is rounded.
+    pub fn to_rgba8_dithered(&self) -> Vec<u8> {
+        self.map_rgba8(|o, p, x, y| {
+            let a = p[3].clamp(0.0, 1.0);
+            let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 };
+            for c in 0..3 {
+                o[c] = dither_u8(p[c] * inv, x, y);
+            }
+            o[3] = (a * 255.0 + 0.5) as u8;
+        })
+    }
     /// Composite over an opaque background colour and return opaque 8-bit RGBA.
     pub fn to_rgba8_over(&self, bg: [f32; 3]) -> Vec<u8> {
-        self.map_rgba8(|o, p| {
+        self.map_rgba8(|o, p, _, _| {
             let k = 1.0 - p[3].clamp(0.0, 1.0);
             for c in 0..3 {
                 o[c] = ((p[c] + bg[c] * k).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
@@ -240,13 +251,23 @@ impl Image {
             o[3] = 255;
         })
     }
-    /// 8-bit RGBA with each pixel written by `f`, row-parallel.
-    fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px) + Sync) -> Vec<u8> {
+    /// [`Image::to_rgba8_over`] with the colour dithered ([`dither_u8`]).
+    pub fn to_rgba8_over_dithered(&self, bg: [f32; 3]) -> Vec<u8> {
+        self.map_rgba8(|o, p, x, y| {
+            let k = 1.0 - p[3].clamp(0.0, 1.0);
+            for c in 0..3 {
+                o[c] = dither_u8(p[c] + bg[c] * k, x, y);
+            }
+            o[3] = 255;
+        })
+    }
+    /// 8-bit RGBA with each pixel written by `f` (given its x and y), row-parallel.
+    fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px, u32, u32) + Sync) -> Vec<u8> {
         let mut out = vec![0u8; self.data.len() * 4];
         let w = self.width.max(1) as usize;
-        out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).for_each(|(o, row)| {
-            for (o, p) in o.as_chunks_mut::<4>().0.iter_mut().zip(row) {
-                f(o, p);
+        out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).enumerate().for_each(|(y, (o, row))| {
+            for (x, (o, p)) in o.as_chunks_mut::<4>().0.iter_mut().zip(row).enumerate() {
+                f(o, p, x as u32, y as u32);
             }
         });
         out
@@ -321,6 +342,22 @@ pub fn hash_noise(x: u32, y: u32, seed: u32) -> f32 {
     (h & 0x00ff_ffff) as f32 / 16_777_216.0
 }
 
+/// Seed of the output dither: fixed, so a frame converts to the same bytes every time.
+const DITHER_SEED: u32 = 0x5bd1_e995;
+
+/// A colour value in 0..1 as an 8-bit code, dithered by pixel position (#2022): the value is
+/// scaled to 0..255 and rounded up or down against a per-pixel hash threshold, so the codes of
+/// a smooth gradient average to the gradient instead of stepping in bands. A value already on
+/// an 8-bit code (anything rendered at 8 bpc) comes out unchanged. Deterministic: depends only
+/// on the value and the pixel position, never on a clock or a global generator.
+#[inline]
+pub fn dither_u8(v: f32, x: u32, y: u32) -> u8 {
+    let s = v.clamp(0.0, 1.0) * 255.0;
+    // The threshold stays inside (1e-4, 1 - 1e-4) so float error on an exact code can't flip it.
+    let t = hash_noise(x, y, DITHER_SEED) * (1.0 - 2e-4) + 1e-4;
+    (s + t).floor() as u8
+}
+
 /// A single-channel coverage mask.
 #[derive(Clone, Debug, Default, PartialEq)]
 pub struct Mask {
@@ -345,6 +382,35 @@ impl Mask {
 mod tests {
     use super::*;
 
+    #[test]
+    fn dither_keeps_exact_codes_and_averages_the_rest() {
+        // A value on an 8-bit code is that code at every position.
+        for code in 0..=255u32 {
+            let v = code as f32 / 255.0;
+            for (x, y) in [(0, 0), (1, 0), (17, 3), (1919, 1079), (4095, 4095)] {
+                assert_eq!(dither_u8(v, x, y) as u32, code, "code {code} at ({x}, {y})");
+            }
+        }
+        // Out-of-range and NaN values saturate instead of wrapping.
+        assert_eq!((dither_u8(-3.0, 5, 5), dither_u8(7.0, 5, 5), dither_u8(f32::NAN, 5, 5)), (0, 255, 0));
+        // A value between two codes lands on both, in proportion: the mean over a block is the value.
+        let v = (100.0 + 0.3) / 255.0;
+        let n = 64u32;
+        let sum: u32 = (0..n * n).map(|i| dither_u8(v, i % n, i / n) as u32).sum();
+        let mean = sum as f32 / (n * n) as f32;
+        assert!((mean - 100.3).abs() < 0.03, "{mean}");
+        // Deterministic.
+        assert_eq!(dither_u8(v, 9, 4), dither_u8(v, 9, 4));
+    }
+
+    #[test]
+    fn dithered_conversions_match_the_plain_ones_on_exact_codes() {
+        let src: Vec<u8> = vec![255, 128, 0, 255, 10, 20, 30, 255, 0, 0, 0, 255, 1, 2, 3, 255];
+        let img = Image::from_rgba8(2, 2, &src);
+        assert_eq!(img.to_rgba8_dithered(), img.to_rgba8());
+        assert_eq!(img.to_rgba8_over_dithered([0.0; 3]), img.to_rgba8_over([0.0; 3]));
+    }
+
     #[test]
     fn rgba8_roundtrip() {
         let src: Vec<u8> = vec![255, 128, 0, 255, 10, 20, 30, 128, 0, 0, 0, 0, 1, 2, 3, 4];
