diff --git a/src/ipa/ipu3/algorithms/meson.build b/src/ipa/ipu3/algorithms/meson.build
index 70177f5..833583e 100644
--- a/src/ipa/ipu3/algorithms/meson.build
+++ b/src/ipa/ipu3/algorithms/meson.build
@@ -7,5 +7,6 @@ ipu3_ipa_algorithms = files([
     'blc.cpp',
     'ccm.cpp',
     'lsc.cpp',
+    'saturation.cpp',
     'tone_mapping.cpp',
 ])
diff --git a/src/ipa/ipu3/algorithms/saturation.cpp b/src/ipa/ipu3/algorithms/saturation.cpp
new file mode 100644
index 0000000..479291d
--- /dev/null
+++ b/src/ipa/ipu3/algorithms/saturation.cpp
@@ -0,0 +1,199 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2026, D. Manresa
+ *
+ * IPU3 Saturation control
+ */
+
+#include "saturation.h"
+
+#include <algorithm>
+#include <cmath>
+#include <string.h>
+
+#include <libcamera/base/log.h>
+
+#include <libcamera/control_ids.h>
+
+#include "libcamera/internal/value_node.h"
+
+/**
+ * \file saturation.h
+ */
+
+namespace libcamera {
+
+namespace ipa::ipu3::algorithms {
+
+/**
+ * \class Saturation
+ * \brief Control of the colour saturation through the ImgU TCC block
+ *
+ * The Total Color Correction (TCC) block of the ImgU applies a chroma gain
+ * that depends on the pixel luminance. When the block is not programmed, the
+ * ImgU driver enables it with a gain look-up table that boosts the chroma by
+ * up to 1.5 in the mid-tones. This algorithm programs the block with the same
+ * default configuration and scales the gain table by the requested
+ * saturation, so that a value of 1.0 reproduces the driver defaults, 0.0
+ * gives a greyscale image and larger values increase the saturation.
+ *
+ * The default saturation is taken from the optional \a saturation tuning
+ * parameter (1.0 if absent) and can be changed at runtime with the
+ * controls::Saturation control.
+ */
+
+LOG_DEFINE_CATEGORY(IPU3Saturation)
+
+namespace {
+
+constexpr double kMinSaturation = 0.0;
+constexpr double kMaxSaturation = 2.0;
+constexpr uint16_t kMaxGain = 4095;
+
+/* imgu_css_tcc_gain_pcwl_lut from the ImgU driver (258 entries) */
+static const uint16_t kDefaultGainPcwl[IPU3_UAPI_YUVP2_TCC_GAIN_PCWL_LUT_ELEMENTS] = {
+	1024, 1032, 1040, 1048, 1057, 1065, 1073, 1081, 1089, 1097, 1105, 1113,
+	1122, 1130, 1138, 1146, 1154, 1162, 1170, 1178, 1187, 1195, 1203, 1211,
+	1219, 1227, 1235, 1243, 1252, 1260, 1268, 1276, 1284, 1292, 1300, 1308,
+	1317, 1325, 1333, 1341, 1349, 1357, 1365, 1373, 1382, 1390, 1398, 1406,
+	1414, 1422, 1430, 1438, 1447, 1455, 1463, 1471, 1479, 1487, 1495, 1503,
+	1512, 1520, 1528, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536,
+	1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536,
+	1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536,
+	1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536,
+	1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536,
+	1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536,
+	1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536,
+	1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536,
+	1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536,
+	1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536,
+	1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536, 1536,
+	1536, 1536, 1528, 1520, 1512, 1503, 1495, 1487, 1479, 1471, 1463, 1455,
+	1447, 1438, 1430, 1422, 1414, 1406, 1398, 1390, 1382, 1373, 1365, 1357,
+	1349, 1341, 1333, 1325, 1317, 1308, 1300, 1292, 1284, 1276, 1268, 1260,
+	1252, 1243, 1235, 1227, 1219, 1211, 1203, 1195, 1187, 1178, 1170, 1162,
+	1154, 1146, 1138, 1130, 1122, 1113, 1105, 1097, 1089, 1081, 1073, 1065,
+	1057, 1048, 1040, 1032, 1024
+};
+
+/* imgu_css_tcc_r_sqr_lut from the ImgU driver (24 entries, last one 0) */
+static const int16_t kDefaultRSqr[IPU3_UAPI_YUVP2_TCC_R_SQR_LUT_ELEMENTS] = {
+	32, 44, 64, 92, 128, 180, 256, 364, 512, 628, 724, 808, 888,
+	956, 1024, 1088, 1144, 1200, 1256, 1304, 1356, 1404, 1448, 0
+};
+
+} /* namespace */
+
+/**
+ * \copydoc libcamera::ipa::Algorithm::init
+ */
+int Saturation::init(IPAContext &context, const ValueNode &tuningData)
+{
+	defaultSaturation_ = std::clamp(tuningData["saturation"].get<double>(1.0),
+					kMinSaturation, kMaxSaturation);
+
+	context.ctrlMap[&controls::Saturation] =
+		ControlInfo(static_cast<float>(kMinSaturation),
+			    static_cast<float>(kMaxSaturation),
+			    static_cast<float>(defaultSaturation_));
+
+	return 0;
+}
+
+/**
+ * \copydoc libcamera::ipa::Algorithm::configure
+ */
+int Saturation::configure(IPAContext &context,
+			  [[maybe_unused]] const IPAConfigInfo &configInfo)
+{
+	context.activeState.saturation.value = defaultSaturation_;
+
+	return 0;
+}
+
+/**
+ * \copydoc libcamera::ipa::Algorithm::queueRequest
+ */
+void Saturation::queueRequest(IPAContext &context, const uint32_t frame,
+			      IPAFrameContext &frameContext,
+			      const ControlList &controls)
+{
+	auto &saturation = context.activeState.saturation;
+
+	frameContext.saturation.update = frame == 0;
+
+	const auto &value = controls.get(controls::Saturation);
+	if (value) {
+		saturation.value = std::clamp<double>(*value, kMinSaturation,
+						      kMaxSaturation);
+		frameContext.saturation.update = true;
+		LOG(IPU3Saturation, Debug) << "Set saturation to " << saturation.value;
+	}
+
+	frameContext.saturation.value = saturation.value;
+}
+
+/**
+ * \copydoc libcamera::ipa::Algorithm::prepare
+ *
+ * Program the TCC block with the ImgU driver defaults, scaling the chroma gain
+ * table by the saturation of the frame.
+ */
+void Saturation::prepare([[maybe_unused]] IPAContext &context,
+			 [[maybe_unused]] const uint32_t frame,
+			 IPAFrameContext &frameContext,
+			 ipu3_uapi_params *params)
+{
+	if (!frameContext.saturation.update)
+		return;
+
+	const double saturation = frameContext.saturation.value;
+	struct ipu3_uapi_yuvp2_tcc_static_config &tcc = params->acc_param.tcc;
+
+	memset(&tcc, 0, sizeof(tcc));
+
+	tcc.gen_control.en = 1;
+	tcc.gen_control.blend_shift = 3;
+	tcc.gen_control.gain_according_to_y_only = 1;
+	tcc.gen_control.gamma = 8;
+	tcc.gen_control.delta = 0;
+
+	for (unsigned int i = 0; i < IPU3_UAPI_YUVP2_TCC_MACC_TABLE_ELEMENTS; i++) {
+		tcc.macc_table.entries[i].a = 1024;
+		tcc.macc_table.entries[i].b = 0;
+		tcc.macc_table.entries[i].c = 0;
+		tcc.macc_table.entries[i].d = 1024;
+	}
+
+	tcc.inv_y_lut.entries[6] = 1023;
+	for (unsigned int i = 7; i < IPU3_UAPI_YUVP2_TCC_INV_Y_LUT_ELEMENTS; i++)
+		tcc.inv_y_lut.entries[i] = 1024 >> (i - 6);
+
+	for (unsigned int i = 0; i < IPU3_UAPI_YUVP2_TCC_GAIN_PCWL_LUT_ELEMENTS; i++) {
+		long gain = std::lround(saturation * kDefaultGainPcwl[i]);
+		tcc.gain_pcwl.entries[i] = std::clamp<long>(gain, 0, kMaxGain);
+	}
+
+	for (unsigned int i = 0; i < IPU3_UAPI_YUVP2_TCC_R_SQR_LUT_ELEMENTS; i++)
+		tcc.r_sqr_lut.entries[i] = kDefaultRSqr[i];
+
+	params->use.acc_tcc = 1;
+}
+
+/**
+ * \copydoc libcamera::ipa::Algorithm::process
+ */
+void Saturation::process([[maybe_unused]] IPAContext &context,
+			 [[maybe_unused]] const uint32_t frame,
+			 IPAFrameContext &frameContext,
+			 [[maybe_unused]] const ipu3_uapi_stats_3a *stats,
+			 ControlList &metadata)
+{
+	metadata.set(controls::Saturation, frameContext.saturation.value);
+}
+
+REGISTER_IPA_ALGORITHM(Saturation, "Saturation")
+
+} /* namespace ipa::ipu3::algorithms */
+
+} /* namespace libcamera */
diff --git a/src/ipa/ipu3/algorithms/saturation.h b/src/ipa/ipu3/algorithms/saturation.h
new file mode 100644
index 0000000..6ce245a
--- /dev/null
+++ b/src/ipa/ipu3/algorithms/saturation.h
@@ -0,0 +1,43 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2026, D. Manresa
+ *
+ * IPU3 Saturation control
+ */
+
+#pragma once
+
+#include <linux/intel-ipu3.h>
+
+#include "algorithm.h"
+
+namespace libcamera {
+
+namespace ipa::ipu3::algorithms {
+
+class Saturation : public Algorithm
+{
+public:
+	Saturation() = default;
+	~Saturation() = default;
+
+	int init(IPAContext &context, const ValueNode &tuningData) override;
+	int configure(IPAContext &context, const IPAConfigInfo &configInfo) override;
+	void queueRequest(IPAContext &context, const uint32_t frame,
+			  IPAFrameContext &frameContext,
+			  const ControlList &controls) override;
+	void prepare(IPAContext &context, const uint32_t frame,
+		     IPAFrameContext &frameContext,
+		     ipu3_uapi_params *params) override;
+	void process(IPAContext &context, const uint32_t frame,
+		     IPAFrameContext &frameContext,
+		     const ipu3_uapi_stats_3a *stats,
+		     ControlList &metadata) override;
+
+private:
+	double defaultSaturation_ = 1.0;
+};
+
+} /* namespace ipa::ipu3::algorithms */
+
+} /* namespace libcamera */
diff --git a/src/ipa/ipu3/data/uncalibrated.yaml b/src/ipa/ipu3/data/uncalibrated.yaml
index 415ca45..b7ab0d7 100644
--- a/src/ipa/ipu3/data/uncalibrated.yaml
+++ b/src/ipa/ipu3/data/uncalibrated.yaml
@@ -9,4 +9,5 @@ algorithms:
   - BlackLevelCorrection:
   - Ccm:
   - ToneMapping:
+  - Saturation:
 ...
diff --git a/src/ipa/ipu3/ipa_context.h b/src/ipa/ipu3/ipa_context.h
index fde628c..76147e3 100644
--- a/src/ipa/ipu3/ipa_context.h
+++ b/src/ipa/ipu3/ipa_context.h
@@ -55,6 +55,10 @@ struct IPAActiveState {
 	ipa::ccm::ActiveState ccm;
 	ipa::gamma::ActiveState gamma;
 	ipa::lsc::ActiveState lsc;
+
+	struct {
+		double value;
+	} saturation;
 };
 
 struct IPAFrameContext : public FrameContext {
@@ -68,6 +72,11 @@ struct IPAFrameContext : public FrameContext {
 	ipa::ccm::FrameContext ccm;
 	ipa::gamma::FrameContext gamma;
 	ipa::lsc::FrameContext lsc;
+
+	struct {
+		double value;
+		bool update;
+	} saturation;
 };
 
 struct IPAContext {
