diff --git a/.changeset/slider-number-input-step-modifiers.md b/.changeset/slider-number-input-step-modifiers.md
new file mode 100644
index 0000000000..026a2e78f8
--- /dev/null
+++ b/.changeset/slider-number-input-step-modifiers.md
@@ -0,0 +1,15 @@
+---
+"@zag-js/number-input": minor
+"@zag-js/slider": minor
+---
+
+Fixed issue where `Cmd`/`Ctrl` + arrow keys produced values off the `step` grid (e.g. non-integer values when
+`step: 1`). Stepping with modifier keys now stays aligned to `step`.
+
+Replaced the implicit modifier-based stepping with explicit, configurable props:
+
+- **Slider**: added `largeStep` (defaults to `10 * step`) used when `Shift` or `PageUp`/`PageDown` is pressed.
+- **Number Input**: added `largeStep` (defaults to `10 * step`, on `Shift`) and `smallStep` (defaults to `step / 10`, on
+ `Alt`).
+
+The defaults preserve the previous stepping magnitudes, so existing behavior is unchanged unless the new props are set.
diff --git a/.nvmrc b/.nvmrc
index a45fd52cc5..b832e4001d 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-24
+24.16.0
diff --git a/e2e/number-input.e2e.ts b/e2e/number-input.e2e.ts
index dfcefb495d..79498dd24f 100644
--- a/e2e/number-input.e2e.ts
+++ b/e2e/number-input.e2e.ts
@@ -100,18 +100,33 @@ test.describe("number input", () => {
await I.seeInputHasValue("0")
})
- test("ctrl+arrowup: should change for 0.1 steps", async () => {
- await I.controls.num("step", "0.1")
+ test("alt+arrowup: should change by smallStep", async () => {
+ await I.type("0")
+
+ await I.pressKey("Alt+ArrowUp")
+ await I.seeInputHasValue("0.1")
- await I.type("0.10", { delay: 20 })
- await I.pressKey("Control+ArrowUp")
- await I.seeInputHasValue("0.11")
+ await I.pressKey("Alt+ArrowUp")
+ await I.seeInputHasValue("0.2")
- await I.pressKey("Control+ArrowDown")
+ await I.pressKey("Alt+ArrowDown")
await I.seeInputHasValue("0.1")
+ })
- await I.pressKey("ArrowDown")
- await I.seeInputHasValue("0")
+ test("should respect custom smallStep and largeStep", async () => {
+ await I.controls.num("largeStep", "5")
+ await I.controls.num("smallStep", "0.25")
+
+ await I.type("0")
+
+ await I.pressKey("Shift+ArrowUp")
+ await I.seeInputHasValue("5")
+
+ await I.pressKey("Alt+ArrowUp")
+ await I.seeInputHasValue("5.25")
+
+ await I.pressKey("Alt+ArrowDown")
+ await I.seeInputHasValue("5")
})
test("inc click: should increment value", async () => {
diff --git a/e2e/slider.e2e.ts b/e2e/slider.e2e.ts
index cf7acdffed..173e077319 100644
--- a/e2e/slider.e2e.ts
+++ b/e2e/slider.e2e.ts
@@ -54,6 +54,37 @@ test.describe("slider", () => {
await I.seeValueText("0")
})
+ test("[keyboard] should stay on the step grid regardless of modifier keys", async () => {
+ await I.focusThumb()
+
+ // meta/ctrl/alt + arrow should move by `step`, never a fraction of it
+ await I.pressKey("Meta+ArrowRight")
+ await I.seeValueText("1")
+
+ await I.pressKey("Control+ArrowRight")
+ await I.seeValueText("2")
+
+ await I.pressKey("Alt+ArrowRight")
+ await I.seeValueText("3")
+
+ await I.pressKey("Alt+ArrowLeft")
+ await I.seeValueText("2")
+ })
+
+ test("[keyboard] should respect a custom largeStep", async () => {
+ await I.controls.num("largeStep", "5")
+ await I.focusThumb()
+
+ await I.pressKey("Shift+ArrowRight")
+ await I.seeValueText("5")
+
+ await I.pressKey("PageUp")
+ await I.seeValueText("10")
+
+ await I.pressKey("PageDown")
+ await I.seeValueText("5")
+ })
+
test("[pointer] should set value on click track", async () => {
await I.mousedownAt({ x: 0.8 })
await I.seeThumbIsFocused()
diff --git a/packages/docs/data/accessibility.json b/packages/docs/data/accessibility.json
index f0e9513631..842e7be39e 100644
--- a/packages/docs/data/accessibility.json
+++ b/packages/docs/data/accessibility.json
@@ -335,19 +335,19 @@
},
{
"keys": ["PageUp"],
- "description": "Increases the value by a larger step"
+ "description": "Increases the value by the largeStep amount."
},
{
"keys": ["PageDown"],
- "description": "Decreases the value by a larger step"
+ "description": "Decreases the value by the largeStep amount."
},
{
"keys": ["Shift + ArrowUp"],
- "description": "Increases the value by a larger step"
+ "description": "Increases the value by the largeStep amount."
},
{
"keys": ["Shift + ArrowDown"],
- "description": "Decreases the value by a larger step"
+ "description": "Decreases the value by the largeStep amount."
},
{
"keys": ["Home"],
@@ -710,12 +710,20 @@
"description": "Decrements the value of the number input by a predefined step."
},
{
- "keys": ["PageUp"],
- "description": "Increments the value of the number input by a larger predefined step."
+ "keys": ["Shift + ArrowUp"],
+ "description": "Increments the value of the number input by the `largeStep` amount."
},
{
- "keys": ["PageDown"],
- "description": "Decrements the value of the number input by a larger predefined step."
+ "keys": ["Shift + ArrowDown"],
+ "description": "Decrements the value of the number input by the `largeStep` amount."
+ },
+ {
+ "keys": ["Alt + ArrowUp"],
+ "description": "Increments the value of the number input by the `smallStep` amount."
+ },
+ {
+ "keys": ["Alt + ArrowDown"],
+ "description": "Decrements the value of the number input by the `smallStep` amount."
},
{
"keys": ["Home"],
diff --git a/packages/docs/data/api.json b/packages/docs/data/api.json
index 293dca73a0..11ce064a6b 100644
--- a/packages/docs/data/api.json
+++ b/packages/docs/data/api.json
@@ -3768,6 +3768,16 @@
"description": "The amount to increment or decrement the value by",
"defaultValue": "1"
},
+ "largeStep": {
+ "type": "number | undefined",
+ "description": "The amount to increment or decrement the value by when the `Shift` key is held.",
+ "defaultValue": "10 * step"
+ },
+ "smallStep": {
+ "type": "number | undefined",
+ "description": "The amount to increment or decrement the value by when the `Alt` key is held.",
+ "defaultValue": "step / 10"
+ },
"allowMouseWheel": {
"type": "boolean | undefined",
"description": "Whether to allow mouse wheel to change the value"
@@ -5240,6 +5250,11 @@
"description": "The step value of the slider",
"defaultValue": "1"
},
+ "largeStep": {
+ "type": "number | undefined",
+ "description": "The step value of the slider when the `Shift` key is held, or the\n`PageUp`/`PageDown` keys are used.",
+ "defaultValue": "10 * step"
+ },
"minStepsBetweenThumbs": {
"type": "number | undefined",
"description": "The minimum permitted steps between multiple thumbs.\n\n`minStepsBetweenThumbs` * `step` should reflect the gap between the thumbs.\n\n- `step: 1` and `minStepsBetweenThumbs: 10` => gap is `10`\n- `step: 10` and `minStepsBetweenThumbs: 2` => gap is `20`",
diff --git a/packages/machines/number-input/src/number-input.connect.ts b/packages/machines/number-input/src/number-input.connect.ts
index aeb62e9717..74138a2dcc 100644
--- a/packages/machines/number-input/src/number-input.connect.ts
+++ b/packages/machines/number-input/src/number-input.connect.ts
@@ -2,7 +2,7 @@ import {
ariaAttr,
dataAttr,
getEventPoint,
- getEventStep,
+ getEventStepValue,
getWindow,
isComposingEvent,
isLeftClick,
@@ -177,7 +177,11 @@ export function connect(
if (readOnly) return
if (isComposingEvent(event)) return
- const step = getEventStep(event) * prop("step")
+ const step = getEventStepValue(event, {
+ step: prop("step"),
+ largeStep: prop("largeStep"),
+ smallStep: prop("smallStep"),
+ })
const keyMap: EventKeyMap = {
ArrowUp() {
diff --git a/packages/machines/number-input/src/number-input.machine.ts b/packages/machines/number-input/src/number-input.machine.ts
index 662c5ca459..8ce0dd5d11 100644
--- a/packages/machines/number-input/src/number-input.machine.ts
+++ b/packages/machines/number-input/src/number-input.machine.ts
@@ -44,6 +44,8 @@ export const machine = createMachine({
max: Number.MAX_SAFE_INTEGER,
spinOnPress: true,
...props,
+ largeStep: props.largeStep ?? 10 * step,
+ smallStep: props.smallStep ?? step / 10,
translations: {
incrementLabel: "increment value",
decrementLabel: "decrease value",
diff --git a/packages/machines/number-input/src/number-input.props.ts b/packages/machines/number-input/src/number-input.props.ts
index 0147fe2f58..1248e695ec 100644
--- a/packages/machines/number-input/src/number-input.props.ts
+++ b/packages/machines/number-input/src/number-input.props.ts
@@ -16,6 +16,7 @@ export const props = createProps()([
"ids",
"inputMode",
"invalid",
+ "largeStep",
"locale",
"max",
"min",
@@ -27,6 +28,7 @@ export const props = createProps()([
"pattern",
"required",
"readOnly",
+ "smallStep",
"spinOnPress",
"step",
"translations",
diff --git a/packages/machines/number-input/src/number-input.types.ts b/packages/machines/number-input/src/number-input.types.ts
index ea1d399a3b..7bea1d7272 100644
--- a/packages/machines/number-input/src/number-input.types.ts
+++ b/packages/machines/number-input/src/number-input.types.ts
@@ -111,6 +111,16 @@ export interface NumberInputProps extends LocaleProperties, CommonProperties {
* @default 1
*/
step?: number | undefined
+ /**
+ * The amount to increment or decrement the value by when the `Shift` key is held.
+ * @default 10 * step
+ */
+ largeStep?: number | undefined
+ /**
+ * The amount to increment or decrement the value by when the `Alt` key is held.
+ * @default step / 10
+ */
+ smallStep?: number | undefined
/**
* Whether to allow mouse wheel to change the value
*/
@@ -177,11 +187,11 @@ type PropsWithDefault =
| "pattern"
| "translations"
| "step"
+ | "largeStep"
+ | "smallStep"
| "spinOnPress"
| "min"
| "max"
- | "step"
- | "translations"
type ComputedContext = Readonly<{
/**
diff --git a/packages/machines/slider/src/slider.connect.ts b/packages/machines/slider/src/slider.connect.ts
index bf30de61ee..016982d558 100644
--- a/packages/machines/slider/src/slider.connect.ts
+++ b/packages/machines/slider/src/slider.connect.ts
@@ -3,7 +3,7 @@ import {
dataAttr,
getEventKey,
getEventPoint,
- getEventStep,
+ getEventStepValue,
isLeftClick,
isModifierKey,
} from "@zag-js/dom-query"
@@ -213,7 +213,7 @@ export function connect(service: SliderService, normalize:
if (event.defaultPrevented) return
if (!interactive) return
- const step = getEventStep(event) * prop("step")
+ const step = getEventStepValue(event, { step: prop("step"), largeStep: prop("largeStep") })
const keyMap: EventKeyMap = {
ArrowUp() {
diff --git a/packages/machines/slider/src/slider.machine.ts b/packages/machines/slider/src/slider.machine.ts
index 9cabca324f..c6ba28f098 100644
--- a/packages/machines/slider/src/slider.machine.ts
+++ b/packages/machines/slider/src/slider.machine.ts
@@ -58,6 +58,7 @@ export const machine = createMachine({
thumbCollisionBehavior: "none",
minStepsBetweenThumbs,
...props,
+ largeStep: props.largeStep ?? 10 * step,
defaultValue: normalize(defaultValue, min, max, step, minStepsBetweenThumbs),
value: props.value ? normalize(props.value, min, max, step, minStepsBetweenThumbs) : undefined,
max,
diff --git a/packages/machines/slider/src/slider.props.ts b/packages/machines/slider/src/slider.props.ts
index 0303047591..73979eba2a 100644
--- a/packages/machines/slider/src/slider.props.ts
+++ b/packages/machines/slider/src/slider.props.ts
@@ -13,6 +13,7 @@ export const props = createProps()([
"id",
"ids",
"invalid",
+ "largeStep",
"max",
"min",
"minStepsBetweenThumbs",
diff --git a/packages/machines/slider/src/slider.types.ts b/packages/machines/slider/src/slider.types.ts
index 154d4bd98a..3d91ff8581 100644
--- a/packages/machines/slider/src/slider.types.ts
+++ b/packages/machines/slider/src/slider.types.ts
@@ -112,6 +112,12 @@ export interface SliderProps extends DirectionProperty, CommonProperties {
* @default 1
*/
step?: number | undefined
+ /**
+ * The step value of the slider when the `Shift` key is held, or the
+ * `PageUp`/`PageDown` keys are used.
+ * @default 10 * step
+ */
+ largeStep?: number | undefined
/**
* The minimum permitted steps between multiple thumbs.
*
@@ -166,6 +172,7 @@ type PropsWithDefault =
| "min"
| "max"
| "step"
+ | "largeStep"
| "orientation"
| "defaultValue"
| "origin"
diff --git a/packages/utilities/dom-query/src/event.ts b/packages/utilities/dom-query/src/event.ts
index ea7eef26e6..bff8e0af1b 100644
--- a/packages/utilities/dom-query/src/event.ts
+++ b/packages/utilities/dom-query/src/event.ts
@@ -120,14 +120,39 @@ export function getNativeEvent(event: E): NativeEvent {
const pageKeys = new Set(["PageUp", "PageDown"])
const arrowKeys = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"])
-export function getEventStep(event: Pick) {
- if (event.ctrlKey || event.metaKey) {
- return 0.1
- } else {
- const isPageKey = pageKeys.has(event.key)
- const isSkipKey = isPageKey || (event.shiftKey && arrowKeys.has(event.key))
- return isSkipKey ? 10 : 1
+export function getEventStep(event: Pick) {
+ const isPageKey = pageKeys.has(event.key)
+ const isSkipKey = isPageKey || (event.shiftKey && arrowKeys.has(event.key))
+ return isSkipKey ? 10 : 1
+}
+
+interface EventStepOptions {
+ /**
+ * The default step amount (no modifier key pressed)
+ */
+ step: number
+ /**
+ * The step amount when `Shift` or `PageUp`/`PageDown` is used
+ */
+ largeStep: number
+ /**
+ * The step amount when `Alt` is used. When omitted, the `Alt` key
+ * has no effect and the default `step` is used.
+ */
+ smallStep?: number | undefined
+}
+
+export function getEventStepValue(
+ event: Pick,
+ options: EventStepOptions,
+): number {
+ const { step, largeStep, smallStep } = options
+ const isArrowKey = arrowKeys.has(event.key)
+ if (smallStep != null && event.altKey && isArrowKey) {
+ return smallStep
}
+ const isLargeStep = pageKeys.has(event.key) || (event.shiftKey && isArrowKey)
+ return isLargeStep ? largeStep : step
}
export function getEventPoint(event: any, type: "page" | "client" = "client"): { x: number; y: number } {
diff --git a/shared/src/controls.ts b/shared/src/controls.ts
index 28e2be7601..e3378e3f13 100644
--- a/shared/src/controls.ts
+++ b/shared/src/controls.ts
@@ -91,6 +91,8 @@ export const numberInputControls = defineControls({
allowMouseWheel: { type: "boolean", defaultValue: false },
spinOnPress: { type: "boolean", defaultValue: true },
step: { type: "number", defaultValue: 1 },
+ largeStep: { type: "number" },
+ smallStep: { type: "number" },
min: { type: "number", defaultValue: 0 },
max: { type: "number", defaultValue: 100 },
locale: {
@@ -141,6 +143,7 @@ export const sliderControls = defineControls({
min: { type: "number", defaultValue: 0 },
max: { type: "number", defaultValue: 100 },
step: { type: "number", defaultValue: 1 },
+ largeStep: { type: "number" },
})
export const tabsControls = defineControls({
diff --git a/website/demos/index.tsx b/website/demos/index.tsx
index a97f4c633a..6d1d4d9138 100644
--- a/website/demos/index.tsx
+++ b/website/demos/index.tsx
@@ -509,7 +509,7 @@ const components = {
invalid: false,
granularity: {
default: "day",
- options: ["day", "month", "year", "hour", "minute", "second"],
+ options: ["day", "hour", "minute", "second"],
},
selectionMode: { default: "single", options: ["single", "range"] },
shouldForceLeadingZeros: false,