diff --git a/include/libcamera/ipa/raspberrypi.h b/include/libcamera/ipa/raspberrypi.h
index a18ce9a..e66402e 100644
--- a/include/libcamera/ipa/raspberrypi.h
+++ b/include/libcamera/ipa/raspberrypi.h
@@ -52,6 +52,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 bc89ab5..b4e42c1 100644
--- a/src/ipa/raspberrypi/raspberrypi.cpp
+++ b/src/ipa/raspberrypi/raspberrypi.cpp
@@ -645,6 +645,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 9d887b7..25cbe13 100644
--- a/src/libcamera/pipeline/raspberrypi/raspberrypi.cpp
+++ b/src/libcamera/pipeline/raspberrypi/raspberrypi.cpp
@@ -37,6 +37,8 @@ namespace libcamera {
 
 LOG_DEFINE_CATEGORY(RPI)
 
+static constexpr int ISP_MIN_SIZE = 64;
+
 using V4L2PixFmtMap = std::map<V4L2PixelFormat, std::vector<SizeRange>>;
 
 namespace {
@@ -322,6 +324,13 @@ public:
 	void handleStreamBuffer(FrameBuffer *buffer, const RPiStream *stream);
 	void handleState();
 
+	void initSensorCrop(Rectangle const &crop)
+	{
+		/* The initial zoom region is the whole of the sensorCrop_. */
+		sensorCrop_ = crop;
+		zoomRect_ = Rectangle{ 0, 0, crop.width, crop.height };
+	}
+
 	CameraSensor *sensor_;
 	/* Array of Unicam and ISP device streams and associated buffers/streams. */
 	RPiDevice<Unicam, 2> unicam_;
@@ -358,6 +367,14 @@ private:
 
 	bool dropFrame_;
 	int ispOutputCount_;
+	/*
+	 * sensorCrop_ 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 sensorCrop_;
+	Rectangle zoomRect_;
 };
 
 class RPiCameraConfiguration : public CameraConfiguration
@@ -763,6 +780,14 @@ int PipelineHandlerRPi::configure(Camera *camera, CameraConfiguration *config)
 
 	crop.x = (sensorFormat.size.width - crop.width) >> 1;
 	crop.y = (sensorFormat.size.height - crop.height) >> 1;
+
+	if (crop.width < ISP_MIN_SIZE || crop.height < ISP_MIN_SIZE) {
+		LOG(RPI, Error) << "Crop from sensor " << crop.width << "x" << crop.height << " is too small";
+		return -1;
+	}
+	sensorCrop_ = Size(crop.width, crop.height);
+	data->initSensorCrop(crop);
+
 	data->isp_[Isp::Input].dev()->setSelection(V4L2_SEL_TGT_CROP, &crop);
 
 	ret = configureIPA(camera);
@@ -1226,6 +1251,13 @@ void RPiCameraData::queueFrameAction(unsigned int frame, const IPAOperationData
 		handleStreamBuffer(buffer, &isp_[Isp::Stats]);
 		/* Fill the Request metadata buffer with what the IPA has provided */
 		requestQueue_.front()->metadata() = std::move(action.controls[0]);
+		ControlList &metadata = requestQueue_.front()->metadata();
+		/*
+		 * The IPA sends the zoom metadata back to us, but we're the ones who
+		 * actually know what we used, so must fill it in correctly.
+		 */
+		if (metadata.contains(controls::DigitalZoom))
+			metadata.set(controls::DigitalZoom, zoomRect_);
 		state_ = State::IpaComplete;
 		break;
 	}
@@ -1548,6 +1580,33 @@ 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 sensorCrop_, coercing it if necessary.
+		 */
+		if (rect.width && rect.height) {
+			zoomRect_ = rect;
+			if (zoomRect_.width < ISP_MIN_SIZE)
+				zoomRect_.width = ISP_MIN_SIZE;
+			else if (zoomRect_.width > sensorCrop_.width)
+				zoomRect_.width = sensorCrop_.width;
+			if (zoomRect_.height < ISP_MIN_SIZE)
+				zoomRect_.height = ISP_MIN_SIZE;
+			else if (zoomRect_.height > sensorCrop_.height)
+				zoomRect_.height = sensorCrop_.height;
+			if (zoomRect_.x + zoomRect_.width > sensorCrop_.width)
+				zoomRect_.x = sensorCrop_.width - zoomRect_.width;
+			if (zoomRect_.y + zoomRect_.height > sensorCrop_.height)
+				zoomRect_.y = sensorCrop_.height - zoomRect_.height;
+		}
+		Rectangle sensorRect = zoomRect_;
+		sensorRect.x += sensorCrop_.x;
+		sensorRect.y += sensorCrop_.y;
+		isp_[Isp::Input].dev()->setSelection(V4L2_SEL_TGT_CROP, &sensorRect);
+	}
+
 	/*
 	 * 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
