vello_cpu(filters): Add offset filter (#1351)

* Implement `FilterPrimitive::Offset` bounds expansion in `vello_common`
* Add `vello_cpu` `Offset` filter using shared in-place pixel shift
helper
* Refactor `DropShadow` to reuse shared shift code
* Add `vello_sparse_tests` coverage + reference snapshot for offset

---------

Co-authored-by: Thomas Churchman <thomas@kepow.org>
diff --git a/sparse_strips/vello_common/src/filter_effects.rs b/sparse_strips/vello_common/src/filter_effects.rs
index 72f9eab..2362d9d 100644
--- a/sparse_strips/vello_common/src/filter_effects.rs
+++ b/sparse_strips/vello_common/src/filter_effects.rs
@@ -20,6 +20,7 @@
 //! - `Flood` - Solid color fill
 //! - `GaussianBlur` - Gaussian blur filter
 //! - `DropShadow` - Drop shadow effect (compound primitive)
+//! - `Offset` - Translation/shift (single primitive)
 //!
 //! **Note:** Currently only single primitive filters are supported. Filter graphs with
 //! multiple connected primitives are not yet implemented.
@@ -36,7 +37,6 @@
 //!
 //! **Filter Primitives:**
 //! - `ColorMatrix` - Matrix-based color transformation
-//! - `Offset` - Geometric offset/translation
 //! - `Composite` - Porter-Duff compositing operations
 //! - `Blend` - Blend mode operations
 //! - `Morphology` - Dilate/erode operations
@@ -569,6 +569,12 @@
                 let radius = (*std_deviation * 3.0) as f64;
                 Rect::new(-radius, -radius, radius, radius)
             }
+            Self::Offset { dx, dy } => {
+                // Offset shifts pixels; expand bounds asymmetrically so shifted content isn't cut.
+                let dx = *dx as f64;
+                let dy = *dy as f64;
+                Rect::new(dx.min(0.0), dy.min(0.0), dx.max(0.0), dy.max(0.0))
+            }
             Self::DropShadow {
                 std_deviation,
                 dx,
@@ -594,6 +600,22 @@
     }
 }
 
+#[cfg(test)]
+mod offset_expansion_tests {
+    use super::FilterPrimitive;
+    use crate::kurbo::Rect;
+
+    #[test]
+    fn offset_expands_in_direction_of_shift() {
+        let p = FilterPrimitive::Offset { dx: 2.5, dy: -3.0 };
+        assert_eq!(
+            p.expansion_rect(),
+            Rect::new(0.0, -3.0, 2.5, 0.0),
+            "Offset expansion should be asymmetric and include the shift vector"
+        );
+    }
+}
+
 /// Unique identifier for a filter primitive in the graph.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
 pub struct FilterId(pub u16);
diff --git a/sparse_strips/vello_cpu/CHANGELOG.md b/sparse_strips/vello_cpu/CHANGELOG.md
index 24450b9..f5442ce 100644
--- a/sparse_strips/vello_cpu/CHANGELOG.md
+++ b/sparse_strips/vello_cpu/CHANGELOG.md
@@ -15,6 +15,10 @@
 
 This release has an [MSRV][] of 1.88.
 
+### Added
+
+- Support for the "offset" filter has been added ([#1351] by [@waywardmonkeys])
+
 ### Changed
 
 - Breaking change: Updated Peniko to [v0.6.0](https://github.com/linebender/peniko/releases/tag/v0.6.0). ([#1349][] by [@DJMcNab][])
@@ -83,6 +87,7 @@
 [@LaurenzV]: https://github.com/LaurenzV
 [@grebmeg]: https://github.com/grebmeg
 [@nicoburns]: https://github.com/nicoburns
+[@waywardmonkeys]: https://github.com/waywardmonkeys
 
 [#1159]: https://github.com/linebender/vello/pull/1159
 [#1203]: https://github.com/linebender/vello/pull/1203
@@ -91,6 +96,7 @@
 [#1294]: https://github.com/linebender/vello/pull/1294
 [#1327]: https://github.com/linebender/vello/pull/1327
 [#1349]: https://github.com/linebender/vello/pull/1349
+[#1351]: https://github.com/linebender/vello/pull/1351
 
 [Unreleased]: https://github.com/linebender/fearless_simd/compare/sparse-strips-v0.0.5...HEAD
 [0.0.5]: https://github.com/linebender/vello/compare/sparse-stips-v0.0.4...sparse-strips-v0.0.5
diff --git a/sparse_strips/vello_cpu/src/filter/drop_shadow.rs b/sparse_strips/vello_cpu/src/filter/drop_shadow.rs
index 1e6a4e4..12f10c3 100644
--- a/sparse_strips/vello_cpu/src/filter/drop_shadow.rs
+++ b/sparse_strips/vello_cpu/src/filter/drop_shadow.rs
@@ -15,8 +15,8 @@
 
 use super::FilterEffect;
 use super::gaussian_blur::{MAX_KERNEL_SIZE, apply_blur, plan_decimated_blur};
+use super::shift::offset_pixels;
 use crate::layer_manager::LayerManager;
-use vello_common::color::palette::css::TRANSPARENT;
 use vello_common::color::{AlphaColor, Srgb};
 use vello_common::filter_effects::EdgeMode;
 use vello_common::peniko::color::PremulRgba8;
@@ -130,137 +130,6 @@
     compose_shadow_direct(&shadow_pixmap, pixmap, color);
 }
 
-/// Shift all pixels in a pixmap by the given offset.
-///
-/// This implements the offset operation in-place by copying pixels to their new positions.
-/// The iteration order is carefully chosen based on shift direction to avoid overwriting
-/// source pixels before they're read. Areas that become exposed (due to the shift) are
-/// filled with transparent black. Pixels that would move outside the bounds are discarded.
-fn offset_pixels(pixmap: &mut Pixmap, dx: f32, dy: f32) {
-    let dx_pixels = dx.round() as i32;
-    let dy_pixels = dy.round() as i32;
-
-    // Early return if no offset
-    if dx_pixels == 0 && dy_pixels == 0 {
-        return;
-    }
-
-    let width = pixmap.width();
-    let height = pixmap.height();
-    let transparent = TRANSPARENT.premultiply().to_rgba8();
-
-    // Process pixels in the correct order to avoid overwriting source data.
-    // Key insight: iterate away from the direction of movement.
-    // This allows us to move pixels in-place without a temporary buffer.
-    //
-    // Use match to eliminate Box<dyn Iterator> allocation overhead and enable
-    // better compiler optimization through static dispatch.
-    match (dx_pixels >= 0, dy_pixels >= 0) {
-        (true, true) => {
-            // Shift right+down: iterate bottom-to-top, right-to-left
-            for y in (0..height).rev() {
-                for x in (0..width).rev() {
-                    process_offset_pixel(
-                        pixmap,
-                        x,
-                        y,
-                        dx_pixels,
-                        dy_pixels,
-                        width,
-                        height,
-                        transparent,
-                    );
-                }
-            }
-        }
-        (true, false) => {
-            // Shift right+up: iterate top-to-bottom, right-to-left
-            for y in 0..height {
-                for x in (0..width).rev() {
-                    process_offset_pixel(
-                        pixmap,
-                        x,
-                        y,
-                        dx_pixels,
-                        dy_pixels,
-                        width,
-                        height,
-                        transparent,
-                    );
-                }
-            }
-        }
-        (false, true) => {
-            // Shift left+down: iterate bottom-to-top, left-to-right
-            for y in (0..height).rev() {
-                for x in 0..width {
-                    process_offset_pixel(
-                        pixmap,
-                        x,
-                        y,
-                        dx_pixels,
-                        dy_pixels,
-                        width,
-                        height,
-                        transparent,
-                    );
-                }
-            }
-        }
-        (false, false) => {
-            // Shift left+up: iterate top-to-bottom, left-to-right
-            for y in 0..height {
-                for x in 0..width {
-                    process_offset_pixel(
-                        pixmap,
-                        x,
-                        y,
-                        dx_pixels,
-                        dy_pixels,
-                        width,
-                        height,
-                        transparent,
-                    );
-                }
-            }
-        }
-    }
-}
-
-/// Process a single pixel during offset operation.
-///
-/// This moves the pixel to its new position (if in bounds) and clears the source
-/// position if it's in the exposed region.
-#[inline(always)]
-fn process_offset_pixel(
-    pixmap: &mut Pixmap,
-    x: u16,
-    y: u16,
-    dx_pixels: i32,
-    dy_pixels: i32,
-    width: u16,
-    height: u16,
-    transparent: PremulRgba8,
-) {
-    let new_x = x as i32 + dx_pixels;
-    let new_y = y as i32 + dy_pixels;
-
-    if new_x >= 0 && new_x < width as i32 && new_y >= 0 && new_y < height as i32 {
-        let pixel = pixmap.sample(x, y);
-        pixmap.set_pixel(new_x as u16, new_y as u16, pixel);
-    }
-
-    // Clear the source pixel if it's in the exposed region
-    let should_clear = (dx_pixels > 0 && x < dx_pixels as u16)
-        || (dx_pixels < 0 && x >= (width as i32 + dx_pixels) as u16)
-        || (dy_pixels > 0 && y < dy_pixels as u16)
-        || (dy_pixels < 0 && y >= (height as i32 + dy_pixels) as u16);
-
-    if should_clear {
-        pixmap.set_pixel(x, y, transparent);
-    }
-}
-
 /// Apply shadow color and composite with original.
 ///
 /// The shadow has already been offset and blurred, so this simply applies
@@ -451,113 +320,6 @@
         );
     }
 
-    /// Test `offset_pixels` with positive offset (right and down).
-    #[test]
-    fn test_offset_pixels_positive() {
-        let mut pixmap = Pixmap::new(4, 4);
-        // Set center pixel to white
-        pixmap.set_pixel(
-            1,
-            1,
-            PremulRgba8 {
-                r: 255,
-                g: 255,
-                b: 255,
-                a: 255,
-            },
-        );
-
-        offset_pixels(&mut pixmap, 1.0, 1.0);
-
-        // White pixel should have moved from (1,1) to (2,2)
-        let moved = pixmap.sample(2, 2);
-        assert_eq!(moved.a, 255);
-
-        // Original position should be cleared (in exposed region)
-        let cleared = pixmap.sample(1, 1);
-        assert_eq!(cleared.a, 0);
-    }
-
-    /// Test `offset_pixels` with negative offset (left and up).
-    #[test]
-    fn test_offset_pixels_negative() {
-        let mut pixmap = Pixmap::new(4, 4);
-        // Set pixel at (2,2) to white
-        pixmap.set_pixel(
-            2,
-            2,
-            PremulRgba8 {
-                r: 255,
-                g: 255,
-                b: 255,
-                a: 255,
-            },
-        );
-
-        offset_pixels(&mut pixmap, -1.0, -1.0);
-
-        // White pixel should have moved from (2,2) to (1,1)
-        let moved = pixmap.sample(1, 1);
-        assert_eq!(moved.a, 255);
-
-        // Original position should be cleared (in exposed region)
-        let cleared = pixmap.sample(2, 2);
-        assert_eq!(cleared.a, 0);
-    }
-
-    /// Test `offset_pixels` with fractional offset (should round).
-    #[test]
-    fn test_offset_pixels_fractional() {
-        let mut pixmap = Pixmap::new(4, 4);
-        pixmap.set_pixel(
-            1,
-            1,
-            PremulRgba8 {
-                r: 255,
-                g: 255,
-                b: 255,
-                a: 255,
-            },
-        );
-
-        // 0.6 rounds to 1, -0.4 rounds to 0
-        offset_pixels(&mut pixmap, 0.6, -0.4);
-
-        // Should move by (1, 0): from (1,1) to (2,1)
-        let moved = pixmap.sample(2, 1);
-        assert_eq!(moved.a, 255);
-    }
-
-    /// Test `offset_pixels` with out-of-bounds offset (should clip).
-    #[test]
-    fn test_offset_pixels_out_of_bounds() {
-        let mut pixmap = Pixmap::new(4, 4);
-        pixmap.set_pixel(
-            1,
-            1,
-            PremulRgba8 {
-                r: 255,
-                g: 255,
-                b: 255,
-                a: 255,
-            },
-        );
-
-        // Large offset that moves pixel outside bounds
-        offset_pixels(&mut pixmap, 10.0, 10.0);
-
-        // Pixel moves outside, so (1,1) should be cleared
-        let cleared = pixmap.sample(1, 1);
-        assert_eq!(cleared.a, 0);
-
-        // All pixels should be cleared (entire image is exposed region)
-        for y in 0..4 {
-            for x in 0..4 {
-                assert_eq!(pixmap.sample(x, y).a, 0);
-            }
-        }
-    }
-
     /// Test `compose_shadow_direct` applies color correctly.
     #[test]
     fn test_compose_shadow_color() {
diff --git a/sparse_strips/vello_cpu/src/filter/mod.rs b/sparse_strips/vello_cpu/src/filter/mod.rs
index ad0c8f7..674d1eb 100644
--- a/sparse_strips/vello_cpu/src/filter/mod.rs
+++ b/sparse_strips/vello_cpu/src/filter/mod.rs
@@ -11,10 +11,13 @@
 mod drop_shadow;
 mod flood;
 mod gaussian_blur;
+mod offset;
+mod shift;
 
 pub(crate) use drop_shadow::DropShadow;
 pub(crate) use flood::Flood;
 pub(crate) use gaussian_blur::GaussianBlur;
+pub(crate) use offset::Offset;
 
 use crate::layer_manager::LayerManager;
 use vello_common::filter_effects::{Filter, FilterPrimitive};
@@ -96,6 +99,10 @@
                 DropShadow::new(scaled_dx, scaled_dy, scaled_std_dev, *edge_mode, *color);
             drop_shadow.execute_lowp(pixmap, layer_manager);
         }
+        FilterPrimitive::Offset { dx, dy } => {
+            let (scaled_dx, scaled_dy) = transform_offset_params(*dx, *dy, &transform);
+            Offset::new(scaled_dx, scaled_dy).execute_lowp(pixmap, layer_manager);
+        }
         _ => {
             // Other primitives like Blend, ColorMatrix, ComponentTransfer, etc.
             // are not yet implemented
@@ -155,6 +162,10 @@
                 DropShadow::new(scaled_dx, scaled_dy, scaled_std_dev, *edge_mode, *color);
             drop_shadow.execute_highp(pixmap, layer_manager);
         }
+        FilterPrimitive::Offset { dx, dy } => {
+            let (scaled_dx, scaled_dy) = transform_offset_params(*dx, *dy, &transform);
+            Offset::new(scaled_dx, scaled_dy).execute_highp(pixmap, layer_manager);
+        }
         _ => {
             // Other primitives like Blend, ColorMatrix, ComponentTransfer, etc.
             // are not yet implemented
@@ -163,6 +174,17 @@
     }
 }
 
+/// Transform an offset's dx/dy using the affine transformation's linear part.
+///
+/// # Returns
+/// A tuple of (`scaled_dx`, `scaled_dy`) in device space.
+fn transform_offset_params(dx: f32, dy: f32, transform: &Affine) -> (f32, f32) {
+    let offset = Vec2::new(dx as f64, dy as f64);
+    let [a, b, c, d, _, _] = transform.as_coeffs();
+    let transformed_offset = Vec2::new(a * offset.x + c * offset.y, b * offset.x + d * offset.y);
+    (transformed_offset.x as f32, transformed_offset.y as f32)
+}
+
 /// Transform a drop shadow's offset and standard deviation using the affine transformation.
 ///
 /// Applies the full linear transformation (rotation, scale, and shear) to the offset vector,
diff --git a/sparse_strips/vello_cpu/src/filter/offset.rs b/sparse_strips/vello_cpu/src/filter/offset.rs
new file mode 100644
index 0000000..fc2924a
--- /dev/null
+++ b/sparse_strips/vello_cpu/src/filter/offset.rs
@@ -0,0 +1,62 @@
+// Copyright 2026 the Vello Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! `feOffset` filter primitive implementation.
+
+use vello_common::pixmap::Pixmap;
+
+use super::FilterEffect;
+use super::shift::offset_pixels;
+use crate::layer_manager::LayerManager;
+
+/// Translation/shift filter.
+///
+/// This shifts the input image by `(dx, dy)` in device pixel space.
+#[derive(Clone, Copy, Debug)]
+pub(crate) struct Offset {
+    dx: f32,
+    dy: f32,
+}
+
+impl Offset {
+    pub(crate) fn new(dx: f32, dy: f32) -> Self {
+        Self { dx, dy }
+    }
+
+    fn execute(self, pixmap: &mut Pixmap, _layer_manager: &mut LayerManager) {
+        offset_pixels(pixmap, self.dx, self.dy);
+    }
+}
+
+impl FilterEffect for Offset {
+    fn execute_lowp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager) {
+        self.execute(pixmap, layer_manager);
+    }
+
+    fn execute_highp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager) {
+        self.execute(pixmap, layer_manager);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::Offset;
+    use crate::filter::FilterEffect;
+    use crate::layer_manager::LayerManager;
+    use vello_common::peniko::color::PremulRgba8;
+    use vello_common::pixmap::Pixmap;
+
+    #[test]
+    fn offset_moves_pixels_and_clears_uncovered_area() {
+        let mut layer_manager = LayerManager::new();
+        let mut pixmap = Pixmap::new(4, 3);
+        pixmap.set_pixel(1, 1, PremulRgba8::from_u32(0xff_00_00_ff)); // premul red, opaque
+
+        Offset::new(2.0, -1.0).execute_lowp(&mut pixmap, &mut layer_manager);
+
+        // Original pixel (1,1) moved to (3,0).
+        assert_eq!(pixmap.sample(3, 0), PremulRgba8::from_u32(0xff_00_00_ff));
+        // Original location cleared.
+        assert_eq!(pixmap.sample(1, 1), PremulRgba8::from_u32(0));
+    }
+}
diff --git a/sparse_strips/vello_cpu/src/filter/shift.rs b/sparse_strips/vello_cpu/src/filter/shift.rs
new file mode 100644
index 0000000..8266079
--- /dev/null
+++ b/sparse_strips/vello_cpu/src/filter/shift.rs
@@ -0,0 +1,253 @@
+// Copyright 2025 the Vello Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! Shared helpers for pixel-space filter operations.
+
+use vello_common::color::palette::css::TRANSPARENT;
+#[cfg(not(feature = "std"))]
+use vello_common::kurbo::common::FloatFuncs as _;
+use vello_common::peniko::color::PremulRgba8;
+use vello_common::pixmap::Pixmap;
+
+/// Shift all pixels in a pixmap by the given offset.
+///
+/// This implements the offset operation in-place by copying pixels to their new positions.
+/// The iteration order is carefully chosen based on shift direction to avoid overwriting
+/// source pixels before they're read. Areas that become exposed (due to the shift) are
+/// filled with transparent black. Pixels that would move outside the bounds are discarded.
+pub(crate) fn offset_pixels(pixmap: &mut Pixmap, dx: f32, dy: f32) {
+    let dx_pixels = dx.round() as i32;
+    let dy_pixels = dy.round() as i32;
+
+    // Early return if no offset
+    if dx_pixels == 0 && dy_pixels == 0 {
+        return;
+    }
+
+    let width = pixmap.width();
+    let height = pixmap.height();
+    let transparent = TRANSPARENT.premultiply().to_rgba8();
+
+    // Process pixels in the correct order to avoid overwriting source data.
+    // Key insight: iterate away from the direction of movement.
+    // This allows us to move pixels in-place without a temporary buffer.
+    //
+    // Use match to eliminate Box<dyn Iterator> allocation overhead and enable
+    // better compiler optimization through static dispatch.
+    match (dx_pixels >= 0, dy_pixels >= 0) {
+        (true, true) => {
+            // Shift right+down: iterate bottom-to-top, right-to-left
+            for y in (0..height).rev() {
+                for x in (0..width).rev() {
+                    process_offset_pixel(
+                        pixmap,
+                        x,
+                        y,
+                        dx_pixels,
+                        dy_pixels,
+                        width,
+                        height,
+                        transparent,
+                    );
+                }
+            }
+        }
+        (true, false) => {
+            // Shift right+up: iterate top-to-bottom, right-to-left
+            for y in 0..height {
+                for x in (0..width).rev() {
+                    process_offset_pixel(
+                        pixmap,
+                        x,
+                        y,
+                        dx_pixels,
+                        dy_pixels,
+                        width,
+                        height,
+                        transparent,
+                    );
+                }
+            }
+        }
+        (false, true) => {
+            // Shift left+down: iterate bottom-to-top, left-to-right
+            for y in (0..height).rev() {
+                for x in 0..width {
+                    process_offset_pixel(
+                        pixmap,
+                        x,
+                        y,
+                        dx_pixels,
+                        dy_pixels,
+                        width,
+                        height,
+                        transparent,
+                    );
+                }
+            }
+        }
+        (false, false) => {
+            // Shift left+up: iterate top-to-bottom, left-to-right
+            for y in 0..height {
+                for x in 0..width {
+                    process_offset_pixel(
+                        pixmap,
+                        x,
+                        y,
+                        dx_pixels,
+                        dy_pixels,
+                        width,
+                        height,
+                        transparent,
+                    );
+                }
+            }
+        }
+    }
+}
+
+/// Process a single pixel during offset operation.
+///
+/// This moves the pixel to its new position (if in bounds) and clears the source
+/// position if it's in the exposed region.
+#[inline(always)]
+fn process_offset_pixel(
+    pixmap: &mut Pixmap,
+    x: u16,
+    y: u16,
+    dx_pixels: i32,
+    dy_pixels: i32,
+    width: u16,
+    height: u16,
+    transparent: PremulRgba8,
+) {
+    let new_x = x as i32 + dx_pixels;
+    let new_y = y as i32 + dy_pixels;
+
+    if new_x >= 0 && new_x < width as i32 && new_y >= 0 && new_y < height as i32 {
+        let pixel = pixmap.sample(x, y);
+        pixmap.set_pixel(new_x as u16, new_y as u16, pixel);
+    }
+
+    // Clear the source pixel if it's in the exposed region
+    let should_clear = (dx_pixels > 0 && x < dx_pixels as u16)
+        || (dx_pixels < 0 && x >= (width as i32 + dx_pixels) as u16)
+        || (dy_pixels > 0 && y < dy_pixels as u16)
+        || (dy_pixels < 0 && y >= (height as i32 + dy_pixels) as u16);
+
+    if should_clear {
+        pixmap.set_pixel(x, y, transparent);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Test `offset_pixels` with positive offset (right and down).
+    #[test]
+    fn test_offset_pixels_positive() {
+        let mut pixmap = Pixmap::new(4, 4);
+        // Set center pixel to white
+        pixmap.set_pixel(
+            1,
+            1,
+            PremulRgba8 {
+                r: 255,
+                g: 255,
+                b: 255,
+                a: 255,
+            },
+        );
+
+        offset_pixels(&mut pixmap, 1.0, 1.0);
+
+        // White pixel should have moved from (1,1) to (2,2)
+        let moved = pixmap.sample(2, 2);
+        assert_eq!(moved.a, 255);
+
+        // Original position should be cleared (in exposed region)
+        let cleared = pixmap.sample(1, 1);
+        assert_eq!(cleared.a, 0);
+    }
+
+    /// Test `offset_pixels` with negative offset (left and up).
+    #[test]
+    fn test_offset_pixels_negative() {
+        let mut pixmap = Pixmap::new(4, 4);
+        // Set pixel at (2,2) to white
+        pixmap.set_pixel(
+            2,
+            2,
+            PremulRgba8 {
+                r: 255,
+                g: 255,
+                b: 255,
+                a: 255,
+            },
+        );
+
+        offset_pixels(&mut pixmap, -1.0, -1.0);
+
+        // White pixel should have moved from (2,2) to (1,1)
+        let moved = pixmap.sample(1, 1);
+        assert_eq!(moved.a, 255);
+
+        // Original position should be cleared (in exposed region)
+        let cleared = pixmap.sample(2, 2);
+        assert_eq!(cleared.a, 0);
+    }
+
+    /// Test `offset_pixels` with fractional offset (should round).
+    #[test]
+    fn test_offset_pixels_fractional() {
+        let mut pixmap = Pixmap::new(4, 4);
+        pixmap.set_pixel(
+            1,
+            1,
+            PremulRgba8 {
+                r: 255,
+                g: 255,
+                b: 255,
+                a: 255,
+            },
+        );
+
+        // 0.6 rounds to 1, -0.4 rounds to 0
+        offset_pixels(&mut pixmap, 0.6, -0.4);
+
+        // Should move by (1, 0): from (1,1) to (2,1)
+        let moved = pixmap.sample(2, 1);
+        assert_eq!(moved.a, 255);
+    }
+
+    /// Test `offset_pixels` with out-of-bounds offset (should clip).
+    #[test]
+    fn test_offset_pixels_out_of_bounds() {
+        let mut pixmap = Pixmap::new(4, 4);
+        pixmap.set_pixel(
+            1,
+            1,
+            PremulRgba8 {
+                r: 255,
+                g: 255,
+                b: 255,
+                a: 255,
+            },
+        );
+
+        // Large offset that moves pixel outside bounds
+        offset_pixels(&mut pixmap, 10.0, 10.0);
+
+        // Pixel moves outside, so (1,1) should be cleared
+        let cleared = pixmap.sample(1, 1);
+        assert_eq!(cleared.a, 0);
+
+        // All pixels should be cleared (entire image is exposed region)
+        for y in 0..4 {
+            for x in 0..4 {
+                assert_eq!(pixmap.sample(x, y).a, 0);
+            }
+        }
+    }
+}
diff --git a/sparse_strips/vello_sparse_tests/snapshots/filter_offset.png b/sparse_strips/vello_sparse_tests/snapshots/filter_offset.png
new file mode 100644
index 0000000..9f88ade
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/filter_offset.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:49b0b4e2fd9a31b9244258197538b5179ba6dba9e6b10bfc69e7cb5e87703c2b
+size 2385
diff --git a/sparse_strips/vello_sparse_tests/tests/filter.rs b/sparse_strips/vello_sparse_tests/tests/filter.rs
index 5c3776b..06764ac 100644
--- a/sparse_strips/vello_sparse_tests/tests/filter.rs
+++ b/sparse_strips/vello_sparse_tests/tests/filter.rs
@@ -7,10 +7,10 @@
 use crate::{renderer::Renderer, util::layout_glyphs_roboto};
 use vello_common::color::AlphaColor;
 use vello_common::color::palette::css::{
-    PURPLE, REBECCA_PURPLE, ROYAL_BLUE, SEA_GREEN, TOMATO, VIOLET, WHITE,
+    BLACK, PURPLE, REBECCA_PURPLE, ROYAL_BLUE, SEA_GREEN, TOMATO, VIOLET, WHITE,
 };
 use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
-use vello_common::kurbo::{Affine, BezPath, Circle, Point, Rect, Shape};
+use vello_common::kurbo::{Affine, BezPath, Circle, Point, Rect, Shape, Stroke};
 use vello_common::mask::Mask;
 use vello_common::peniko::{BlendMode, Compose, Mix};
 use vello_common::pixmap::Pixmap;
@@ -884,6 +884,43 @@
     ctx.pop_layer();
 }
 
+/// Test offset filter primitive.
+///
+/// This shifts content within a filter layer and should not clip content to the original bounds.
+#[vello_test(skip_hybrid, skip_multithreaded)]
+fn filter_offset(ctx: &mut impl Renderer) {
+    let filter = Filter::from_primitive(FilterPrimitive::Offset {
+        dx: 18.0,
+        dy: -12.0,
+    });
+    let star_path = circular_star(Point::new(50.0, 50.0), 7, 10.0, 22.0);
+
+    // Draw the unfiltered star as an outline at the original position.
+    ctx.set_paint(ROYAL_BLUE);
+    ctx.set_stroke(Stroke::new(1.5));
+    ctx.stroke_path(&star_path);
+
+    // Draw a marker rect at a known coordinate.
+    //
+    // This avoids trying to reason about pixel movement from an anti-aliased stroke edge.
+    let marker = Rect::new(49.0, 27.0, 53.0, 31.0);
+    ctx.set_paint(SEA_GREEN);
+    ctx.fill_rect(&marker);
+
+    // Draw the filtered (shifted) star as a fill and stroke, then draw the marker through the
+    // filter layer as well.
+    ctx.push_filter_layer(filter);
+    ctx.set_paint(TOMATO);
+    ctx.fill_path(&star_path);
+    ctx.set_paint(BLACK);
+    ctx.set_stroke(Stroke::new(1.5));
+    ctx.stroke_path(&star_path);
+    // With (dx, dy) = (18, -12) this should land at (67, 15).
+    ctx.set_paint(VIOLET);
+    ctx.fill_rect(&marker);
+    ctx.pop_layer();
+}
+
 /// Test blur with various transforms (translate, rotate, scale, skew).
 #[vello_test(skip_hybrid, skip_multithreaded)]
 fn filter_transformed_blur(ctx: &mut impl Renderer) {