diff --git a/docs/config_options.md b/docs/config_options.md
index 95cc89eaa7ee..43b25953324a 100644
--- a/docs/config_options.md
+++ b/docs/config_options.md
@@ -190,6 +190,10 @@ If you define these options you will enable the associated feature, which may in
* Sets the key repeat interval for [key overrides](features/key_overrides).
* `#define LEGACY_MAGIC_HANDLING`
* Enables magic configuration handling for advanced keycodes (such as Mod Tap and Layer Tap)
+* `#define PROGRESSIVE_KEYBOARD_REPORTS`
+ * Splits a single logical report change into ordered sub-reports so that modifier and key ordering is preserved on the host: keys that are no longer held are released first, then the new modifier byte is applied against the surviving keys, then the full report is sent. This guarantees that e.g. `Shift` is registered before the key it modifies (and that keys release before their modifiers), avoiding mis-shifted characters on fast or chorded input. When the define is absent, report emission is unchanged. Has no effect on `PROTOCOL_VUSB` boards, which always send a single report.
+* `#define PROGRESSIVE_REPORT_DELAY 1`
+ * Optional. When `PROGRESSIVE_KEYBOARD_REPORTS` is enabled, sets the delay in milliseconds inserted between each sub-report. If not defined, no delay is added.
## RGB Light Configuration
diff --git a/quantum/action_util.c b/quantum/action_util.c
index 00cec24e3f0f..a0004ed45983 100644
--- a/quantum/action_util.c
+++ b/quantum/action_util.c
@@ -24,6 +24,10 @@ along with this program. If not, see .
#include "keycode_config.h"
#include
+#if defined(PROGRESSIVE_KEYBOARD_REPORTS) && defined(PROGRESSIVE_REPORT_DELAY)
+# include "wait.h"
+#endif // defined(PROGRESSIVE_KEYBOARD_REPORTS) && defined(PROGRESSIVE_REPORT_DELAY)
+
extern keymap_config_t keymap_config;
static uint8_t real_mods = 0;
@@ -306,6 +310,42 @@ void send_6kro_report(void) {
#else
static report_keyboard_t last_report;
+# ifdef PROGRESSIVE_KEYBOARD_REPORTS
+# ifdef KEYBOARD_SHARED_EP
+ last_report.report_id = keyboard_report->report_id;
+# endif // KEYBOARD_SHARED_EP
+
+ /* Release any keys that are no longer held before applying the new mods. This only splits out
+ key releases (a slot cleared to 0); a slot that changes from one key to another in a single
+ scan is not split, as 6KRO reports keys positionally rather than as a bitmap. */
+ if (memcmp(keyboard_report->keys, last_report.keys, sizeof(keyboard_report->keys)) != 0) {
+ bool changed = false;
+ for (uint8_t i = 0; i < KEYBOARD_REPORT_KEYS; ++i) {
+ if (keyboard_report->keys[i] == 0) {
+ if (last_report.keys[i] != 0) {
+ last_report.keys[i] = 0;
+ changed = true;
+ }
+ }
+ }
+ if (changed) {
+ host_keyboard_send(&last_report);
+# ifdef PROGRESSIVE_REPORT_DELAY
+ wait_ms(PROGRESSIVE_REPORT_DELAY);
+# endif // PROGRESSIVE_REPORT_DELAY
+ }
+ }
+
+ /* Send the new mods alongside the keys that are still held. */
+ if (keyboard_report->mods != last_report.mods) {
+ last_report.mods = keyboard_report->mods;
+ host_keyboard_send(&last_report);
+# ifdef PROGRESSIVE_REPORT_DELAY
+ wait_ms(PROGRESSIVE_REPORT_DELAY);
+# endif // PROGRESSIVE_REPORT_DELAY
+ }
+# endif // PROGRESSIVE_KEYBOARD_REPORTS
+
/* Only send the report if there are changes to propagate to the host. */
if (memcmp(keyboard_report, &last_report, sizeof(report_keyboard_t)) != 0) {
memcpy(&last_report, keyboard_report, sizeof(report_keyboard_t));
@@ -320,6 +360,37 @@ void send_nkro_report(void) {
static report_nkro_t last_report;
+# ifdef PROGRESSIVE_KEYBOARD_REPORTS
+ last_report.report_id = nkro_report->report_id;
+
+ /* Remove existing keys that aren't in the intended report. */
+ if (memcmp(nkro_report->bits, last_report.bits, sizeof(nkro_report->bits)) != 0) {
+ bool changed = false;
+ for (uint8_t i = 0; i < NKRO_REPORT_BITS; ++i) {
+ uint8_t orig = last_report.bits[i];
+ last_report.bits[i] &= nkro_report->bits[i];
+ if (last_report.bits[i] != orig) {
+ changed = true;
+ }
+ }
+ if (changed) {
+ host_nkro_send(&last_report);
+# ifdef PROGRESSIVE_REPORT_DELAY
+ wait_ms(PROGRESSIVE_REPORT_DELAY);
+# endif // PROGRESSIVE_REPORT_DELAY
+ }
+ }
+
+ /* Send the new mods with the intersecting set of keys */
+ if (nkro_report->mods != last_report.mods) {
+ last_report.mods = nkro_report->mods;
+ host_nkro_send(&last_report);
+# ifdef PROGRESSIVE_REPORT_DELAY
+ wait_ms(PROGRESSIVE_REPORT_DELAY);
+# endif // PROGRESSIVE_REPORT_DELAY
+ }
+# endif // PROGRESSIVE_KEYBOARD_REPORTS
+
/* Only send the report if there are changes to propagate to the host. */
if (memcmp(nkro_report, &last_report, sizeof(report_nkro_t)) != 0) {
memcpy(&last_report, nkro_report, sizeof(report_nkro_t));
diff --git a/tests/progressive_keyboard_reports/config.h b/tests/progressive_keyboard_reports/config.h
new file mode 100644
index 000000000000..0c9bfb646fea
--- /dev/null
+++ b/tests/progressive_keyboard_reports/config.h
@@ -0,0 +1,21 @@
+/* Copyright 2026 QMK
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include "test_common.h"
+
+#define PROGRESSIVE_KEYBOARD_REPORTS
diff --git a/tests/progressive_keyboard_reports/test.mk b/tests/progressive_keyboard_reports/test.mk
new file mode 100644
index 000000000000..85ba61359009
--- /dev/null
+++ b/tests/progressive_keyboard_reports/test.mk
@@ -0,0 +1,18 @@
+# Copyright 2026 QMK
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# --------------------------------------------------------------------------------
+# Keep this file, even if it is empty, as a marker that this folder contains tests
+# --------------------------------------------------------------------------------
diff --git a/tests/progressive_keyboard_reports/test_progressive_keyboard_reports.cpp b/tests/progressive_keyboard_reports/test_progressive_keyboard_reports.cpp
new file mode 100644
index 000000000000..9f5b17ed987d
--- /dev/null
+++ b/tests/progressive_keyboard_reports/test_progressive_keyboard_reports.cpp
@@ -0,0 +1,84 @@
+/* Copyright 2026 QMK
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "action_util.h"
+#include "keyboard_report_util.hpp"
+#include "test_common.hpp"
+
+using testing::_;
+using testing::AnyNumber;
+using testing::InSequence;
+
+// These tests drive the QMK core report builders directly (add_mods/add_key/... then a single
+// send_keyboard_report()) so that a modifier and a key change land in one report transition,
+// which is what exercises the progressive reporting. The calls are qualified with `::` because the
+// test body is a TestFixture member, and TestFixture::add_key(KeymapKey) would otherwise shadow
+// the global core function ::add_key(uint8_t). The mod helpers have no such collision, but are
+// qualified too for symmetry and to make clear these are core functions, not fixture helpers.
+
+class ProgressiveKeyboardReports : public TestFixture {};
+
+// On press, a single logical change that adds both a modifier and a key must be
+// split so the modifier reaches the host first, guaranteeing the key is shifted.
+TEST_F(ProgressiveKeyboardReports, ModifierIsReportedBeforeKeyOnPress) {
+ TestDriver driver;
+ InSequence s;
+
+ EXPECT_REPORT(driver, (KC_LEFT_SHIFT));
+ EXPECT_REPORT(driver, (KC_LEFT_SHIFT, KC_A));
+
+ ::add_mods(MOD_BIT(KC_LEFT_SHIFT));
+ ::add_key(KC_A);
+ send_keyboard_report();
+
+ VERIFY_AND_CLEAR(driver);
+}
+
+// On release, the same change in reverse must clear the key while the modifier
+// is still held, then clear the modifier, so the key never registers unshifted.
+TEST_F(ProgressiveKeyboardReports, KeyIsReleasedBeforeModifierOnRelease) {
+ TestDriver driver;
+
+ /* Establish the held state: Shift + A. */
+ EXPECT_ANY_REPORT(driver).Times(AnyNumber());
+ ::add_mods(MOD_BIT(KC_LEFT_SHIFT));
+ ::add_key(KC_A);
+ send_keyboard_report();
+ VERIFY_AND_CLEAR(driver);
+
+ InSequence s;
+
+ EXPECT_REPORT(driver, (KC_LEFT_SHIFT));
+ EXPECT_EMPTY_REPORT(driver);
+
+ ::del_mods(MOD_BIT(KC_LEFT_SHIFT));
+ ::del_key(KC_A);
+ send_keyboard_report();
+
+ VERIFY_AND_CLEAR(driver);
+}
+
+// With no modifier change, a plain key press still collapses to a single report.
+TEST_F(ProgressiveKeyboardReports, PlainKeyPressSendsSingleReport) {
+ TestDriver driver;
+
+ EXPECT_REPORT(driver, (KC_A)).Times(1);
+
+ ::add_key(KC_A);
+ send_keyboard_report();
+
+ VERIFY_AND_CLEAR(driver);
+}