diff --git a/src/lib/components/SpinButton/README.md b/src/lib/components/SpinButton/README.md
new file mode 100644
index 0000000..ecdc9d1
--- /dev/null
+++ b/src/lib/components/SpinButton/README.md
@@ -0,0 +1,6 @@
+---
+path: component/spin-button
+title: Spin Button
+---
+
+# Spin Button
diff --git a/src/lib/components/SpinButton/example/+page.svelte b/src/lib/components/SpinButton/example/+page.svelte
new file mode 100644
index 0000000..e3c0ca5
--- /dev/null
+++ b/src/lib/components/SpinButton/example/+page.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/lib/components/SpinButton/example/Quantity.svelte b/src/lib/components/SpinButton/example/Quantity.svelte
new file mode 100644
index 0000000..ba0ac19
--- /dev/null
+++ b/src/lib/components/SpinButton/example/Quantity.svelte
@@ -0,0 +1,28 @@
+
+
+
diff --git a/src/lib/components/SpinButton/index.ts b/src/lib/components/SpinButton/index.ts
new file mode 100644
index 0000000..53bc193
--- /dev/null
+++ b/src/lib/components/SpinButton/index.ts
@@ -0,0 +1,2 @@
+export * from './spinButton.js'
+export * from './spinButton.types.js'
diff --git a/src/lib/components/SpinButton/spinButton.ts b/src/lib/components/SpinButton/spinButton.ts
new file mode 100644
index 0000000..3519060
--- /dev/null
+++ b/src/lib/components/SpinButton/spinButton.ts
@@ -0,0 +1,222 @@
+import { onBrowserMount } from '$lib/helpers/environment.js'
+import { generateId } from '$lib/helpers/uuid.js'
+import { derived, get, readonly, writable } from 'svelte/store'
+import type { SpinButton, SpinButtonConfig } from './spinButton.types.js'
+
+export const createSpinButton = (config?: SpinButtonConfig): SpinButton => {
+ const { disabled, min, max, step, value, strict } = { ...config }
+
+ const value$ = writable(value || 0)
+ const disabled$ = writable(disabled || false)
+ const invalid$ = writable(false)
+
+ const min$ = writable(min || 0)
+ const max$ = writable(max || 100)
+ const step$ = writable(step || 1)
+
+ const baseId = generateId()
+ const inputId = `${baseId}-input`
+ const incrementId = `${baseId}-increment`
+ const decrementId = `${baseId}-decrement`
+
+ const inputAttrs = derived(
+ [disabled$, value$, invalid$],
+ ([disabled, value, invalid]) => ({
+ role: 'spinbutton',
+ 'aria-disabled': disabled,
+ 'aria-valuemin': config?.min || 0,
+ 'aria-valuemax': config?.max || 100,
+ 'aria-valuenow': value,
+ 'aria-invalid': invalid && !strict ? true : undefined,
+ 'data-spinbutton-input': inputId,
+ })
+ )
+
+ // If the value is above or equal to the max, disable the increment button
+ const incrementButtonAttrs = derived(
+ [disabled$, value$, max$],
+ ([disabled, value, max]) => {
+ const isDisabled = value >= max || disabled
+ return {
+ role: 'button',
+ 'aria-disabled': isDisabled,
+ tabIndex: isDisabled ? -1 : 0,
+ 'data-spinbutton-more': incrementId,
+ }
+ }
+ )
+
+ const decrementButtonAttrs = derived(
+ [disabled$, value$, min$],
+ ([disabled, value, min]) => {
+ const isDisabled = value <= min || disabled
+ return {
+ role: 'button',
+ 'aria-disabled': isDisabled,
+ tabIndex: isDisabled ? -1 : 0,
+ 'data-spinbutton-less': decrementId,
+ }
+ }
+ )
+
+ const increase = () => {
+ const disabled = get(disabled$)
+
+ if (disabled) {
+ return
+ }
+
+ const value = get(value$)
+ const max = get(max$)
+ const step = get(step$)
+ const newValue = value + step
+
+ if (newValue > max) {
+ value$.set(max)
+ } else {
+ value$.set(value + step)
+ }
+ }
+
+ const decrease = () => {
+ const disabled = get(disabled$)
+
+ if (disabled) {
+ return
+ }
+
+ const value = get(value$)
+ const min = get(min$)
+ const step = get(step$)
+ const newValue = value - step
+
+ if (newValue < min) {
+ value$.set(min)
+ } else {
+ value$.set(value - step)
+ }
+ }
+
+ const onInputChange = (event: Event) => {
+ const disabled = get(disabled$)
+
+ if (disabled) {
+ return
+ }
+
+ const target = event.target as HTMLInputElement
+ const value = Number(target.value)
+
+ if (Number.isNaN(value)) {
+ invalid$.set(true)
+ return
+ }
+
+ const min = get(min$)
+ const max = get(max$)
+
+ if (value < min || value > max) {
+ invalid$.set(true)
+ return
+ }
+ }
+
+ const onIncrementClick = (event: MouseEvent) => {
+ event.preventDefault()
+ increase()
+ }
+
+ const onIncrementKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ increase()
+ }
+ }
+
+ const onDecrementClick = (event: MouseEvent) => {
+ event.preventDefault()
+ decrease()
+ }
+
+ const onDecrementKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ decrease()
+ }
+ }
+
+ onBrowserMount(() => {
+ const inputNode = document.querySelector(
+ `[data-spinbutton-input="${inputId}"]`
+ )
+
+ if (!inputNode) {
+ throw new Error('Could not find the input for the spin button')
+ }
+
+ const incrementNode = document.querySelector(
+ `[data-spinbutton-more="${incrementId}"]`
+ )
+
+ if (!incrementNode) {
+ throw new Error('Could not find the increment button for the spin button')
+ }
+
+ const decrementNode = document.querySelector(
+ `[data-spinbutton-less="${decrementId}"]`
+ )
+
+ if (!decrementNode) {
+ throw new Error('Could not find the decrement button for the spin button')
+ }
+
+ inputNode.addEventListener('change', onInputChange)
+ incrementNode.addEventListener('click', onIncrementClick)
+ incrementNode.addEventListener('keydown', onIncrementKeyDown)
+ decrementNode.addEventListener('click', onDecrementClick)
+ decrementNode.addEventListener('keydown', onDecrementKeyDown)
+
+ // Update the input value and invalid state when the value changes
+ const unsubscribe = value$.subscribe((value) => {
+ inputNode.value = value.toString()
+
+ const min = get(min$)
+ const max = get(max$)
+ let isInvalid = false
+
+ if (strict) {
+ if (value < min) {
+ value$.set(min)
+ } else if (value > max) {
+ value$.set(max)
+ }
+ } else {
+ if (value < min || value > max) {
+ isInvalid = true
+ }
+ }
+
+ invalid$.set(isInvalid)
+ })
+
+ return () => {
+ unsubscribe()
+ inputNode.removeEventListener('change', onInputChange)
+ incrementNode.removeEventListener('click', onIncrementClick)
+ incrementNode.removeEventListener('keydown', onIncrementKeyDown)
+ decrementNode.removeEventListener('click', onDecrementClick)
+ decrementNode.removeEventListener('keydown', onDecrementKeyDown)
+ }
+ })
+
+ return {
+ value: value$,
+ disabled: disabled$,
+ invalid: readonly(invalid$),
+ inputAttrs,
+ incrementButtonAttrs,
+ decrementButtonAttrs,
+ increase,
+ decrease,
+ }
+}
diff --git a/src/lib/components/SpinButton/spinButton.types.ts b/src/lib/components/SpinButton/spinButton.types.ts
new file mode 100644
index 0000000..e11d35c
--- /dev/null
+++ b/src/lib/components/SpinButton/spinButton.types.ts
@@ -0,0 +1,39 @@
+import type { HTMLAttributes } from '$lib/helpers/types.js'
+import type { Readable, Writable } from 'svelte/store'
+
+export type SpinButtonConfig = {
+ /** If true, the input will be disabled. */
+ disabled?: boolean
+
+ /** If true, the input will be readonly. */
+ readonly?: boolean
+
+ /** The initial value of the input. Defaults to 0. */
+ value?: number
+
+ /** The minimum value of the input. Defaults to 0. */
+ min?: number
+
+ /** The maximum value of the input. Defaults to 100. */
+ max?: number
+
+ /** The amount to increment or decrement the value by. Defaults to 1. */
+ step?: number
+
+ /**
+ * If true, the value will be clamped to the min and max values. And the input
+ * will never be marked as invalid.
+ */
+ strict?: boolean
+}
+
+export type SpinButton = {
+ value: Writable
+ disabled: Writable
+ invalid: Readable
+ inputAttrs: Readable
+ incrementButtonAttrs: Readable
+ decrementButtonAttrs: Readable
+ increase: () => void
+ decrease: () => void
+}
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 471461c..dec8957 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -13,6 +13,7 @@ export * from './components/Menu/index.js'
export * from './components/Popover/index.js'
export * from './components/RadioGroup/index.js'
export * from './components/Select/index.js'
+export * from './components/SpinButton/index.js'
export * from './components/Switch/index.js'
export * from './components/Tabs/index.js'
export * from './components/TagGroup/index.js'