diff --git a/include/libcamera/ipa/raspberrypi.h b/include/libcamera/ipa/raspberrypi.h
index a493776..f42e13e 100644
--- a/include/libcamera/ipa/raspberrypi.h
+++ b/include/libcamera/ipa/raspberrypi.h
@@ -56,6 +56,7 @@ static const ControlInfoMap RPiControls = {
 	{ &controls::Contrast, ControlInfo(0.0f, 32.0f) },
 	{ &controls::Saturation, ControlInfo(0.0f, 32.0f) },
 	{ &controls::Sharpness, ControlInfo(0.0f, 16.0f, 1.0f) },
+	{ &controls::DigitalZoom, ControlInfo(Rectangle(0, 0, 0, 0), Rectangle(65535, 65535, 65535, 65535), Rectangle(0, 0, 0, 0)) },
 };
 
 } /* namespace libcamera */
diff --git a/src/ipa/raspberrypi/raspberrypi.cpp b/src/ipa/raspberrypi/raspberrypi.cpp
index 7bd0488..f759d4d 100644
--- a/src/ipa/raspberrypi/raspberrypi.cpp
+++ b/src/ipa/raspberrypi/raspberrypi.cpp
@@ -679,6 +679,16 @@ void IPARPi::queueRequest(const ControlList &controls)
 			break;
 		}
 
+		case controls::DIGITAL_ZOOM: {
+			/*
+			 * We send the zoom info back in the metadata where the pipeline
+			 * handler will update it to the values actually used.
+			 */
+			Rectangle crop = ctrl.second.get<Rectangle>();
+			libcameraMetadata_.set(controls::DigitalZoom, crop);
+			break;
+		}
+
 		default:
 			LOG(IPARPI, Warning)
 				<< "Ctrl " << controls::controls.at(ctrl.first)->name()
diff --git a/src/libcamera/pipeline/raspberrypi/raspberrypi.cpp b/src/libcamera/pipeline/raspberrypi/raspberrypi.cpp
index bf1c771..6bc595f 100644
--- a/src/libcamera/pipeline/raspberrypi/raspberrypi.cpp
+++ b/src/libcamera/pipeline/raspberrypi/raspberrypi.cpp
@@ -308,6 +308,14 @@ public:
 	void handleStreamBuffer(FrameBuffer *buffer, const RPiStream *stream);
 	void handleState();
 
+	void initPipelineCrop(Rectangle const &crop, Size const &ispMinSize)
+	{
+		/* The initial zoom region is the whole of the pipelineCrop_. */
+		pipelineCrop_ = crop;
+		zoomRect_ = Rectangle(0, 0, crop.width, crop.height);
+		ispMinSize_ = ispMinSize;
+	}
+
 	CameraSensor *sensor_;
 	/* Array of Unicam and ISP device streams and associated buffers/streams. */
 	RPiDevice<Unicam, 2> unicam_;
@@ -344,6 +352,15 @@ private:
 
 	bool dropFrame_;
 	int ispOutputCount_;
+	/*
+	 * pipelineCrop_ is the largest region of the full sensor output that matches
+	 * the desired aspect ratio, and therefore represents the area within
+	 * which we can pan and zoom. zoomRect_ is the portion from within the
+	 * sensorCrop_ that pan/zoom is currently using.
+	 */
+	Rectangle pipelineCrop_;
+	Rectangle zoomRect_;
+	Size ispMinSize_;
 };
 
 class RPiCameraConfiguration : public CameraConfiguration
@@ -736,6 +753,10 @@ int PipelineHandlerRPi::configure(Camera *camera, CameraConfiguration *config)
 		return ret;
 	}
 
+	Rectangle testCrop(0, 0, 1, 1);
+	data->isp_[Isp::Input].dev()->setSelection(V4L2_SEL_TGT_CROP, &testCrop);
+	unsigned int minSize = std::max(testCrop.width, testCrop.height);
+
 	/* Adjust aspect ratio by providing crops on the input image. */
 	Rectangle crop{ 0, 0, sensorFormat.size };
 
@@ -750,8 +771,17 @@ int PipelineHandlerRPi::configure(Camera *camera, CameraConfiguration *config)
 
 	crop.x = (sensorFormat.size.width - crop.width) >> 1;
 	crop.y = (sensorFormat.size.height - crop.height) >> 1;
+
 	data->isp_[Isp::Input].dev()->setSelection(V4L2_SEL_TGT_CROP, &crop);
 
+	pipelineCrop_ = Size(crop.width, crop.height);
+	Size ispMinSize(minSize, minSize);
+	if (crop.width > crop.height)
+		ispMinSize.width = minSize * crop.width / crop.height;
+	else if (crop.width < crop.height)
+		ispMinSize.height = minSize * crop.height / crop.width;
+	data->initPipelineCrop(crop, ispMinSize);
+
 	ret = data->configureIPA();
 	if (ret)
 		LOG(RPI, Error) << "Failed to configure the IPA: " << ret;
@@ -1488,6 +1518,30 @@ void RPiCameraData::checkRequestCompleted()
 	}
 }
 
+static void fixup_dimension(int &start, unsigned int &size,
+			    unsigned int minSize, unsigned int maxSize)
+{
+	/*
+	 * If the size being requested it too small we must increase it, but
+	 * we try to keep the mid point the same. Though if this would mean
+	 * extending beyond the available bounds, then we have to line ourselves
+	 * up flush.
+	 */
+	int mid = start + size / 2;
+	size = std::min(std::max(size, minSize), maxSize);
+	start = mid - size / 2;
+	if (start < 0)
+		start = 0;
+	else if (start + size > maxSize)
+		start = maxSize - size;
+}
+
+static void fixup_rectangle(Rectangle &region, Size const &minSize, Size const &maxSize)
+{
+	fixup_dimension(region.x, region.width, minSize.width, maxSize.width);
+	fixup_dimension(region.y, region.height, minSize.height, maxSize.height);
+}
+
 void RPiCameraData::tryRunPipeline()
 {
 	FrameBuffer *bayerBuffer, *embeddedBuffer;
@@ -1541,6 +1595,24 @@ void RPiCameraData::tryRunPipeline()
 	 */
 	Request *request = requestQueue_.front();
 
+	if (request->controls().contains(controls::DigitalZoom)) {
+		Rectangle rect = request->controls().get<Rectangle>(controls::DigitalZoom);
+		/*
+		 * If a new digital zoom value was given, check that it lies within the
+		 * available pipelineCrop_, and that it's not smaller than permitted.
+		 */
+		if (rect.width && rect.height) {
+			zoomRect_ = rect;
+			fixup_rectangle(zoomRect_, ispMinSize_,
+					Size(pipelineCrop_.width, pipelineCrop_.height));
+		}
+		Rectangle sensorRect = zoomRect_;
+		sensorRect.x += pipelineCrop_.x;
+		sensorRect.y += pipelineCrop_.y;
+		isp_[Isp::Input].dev()->setSelection(V4L2_SEL_TGT_CROP, &sensorRect);
+		request->controls().set(controls::DigitalZoom, zoomRect_);
+	}
+
 	/*
 	 * Process all the user controls by the IPA. Once this is complete, we
 	 * queue the ISP output buffer listed in the request to start the HW
