diff --git a/.changeset/big-fishes-double.md b/.changeset/big-fishes-double.md
new file mode 100644
index 000000000..a75ce4e74
--- /dev/null
+++ b/.changeset/big-fishes-double.md
@@ -0,0 +1,5 @@
+---
+"bits-ui": patch
+---
+
+fix: pin input pattern checking
diff --git a/packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts b/packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts
index 95fe3d9e9..c8bb7e06b 100644
--- a/packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts
+++ b/packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts
@@ -37,7 +37,10 @@ type PinInputRootStateProps = WithRefProps<
 		}>
 >;
 
-type PrevInputMetadata = [number | null, number | null, "none" | "forward" | "backward"];
+type PrevInputMetadata = {
+	prev: [number | null, number | null, "none" | "forward" | "backward"];
+	willSyntheticBlur: boolean;
+};
 type InitialLoad = {
 	value: WritableBox<string>;
 	isIOS: boolean;
@@ -69,7 +72,10 @@ class PinInputRootState {
 			return this.#pattern.current;
 		}
 	});
-	#prevInputMetadata = $state<PrevInputMetadata>([null, null, "none"]);
+	#prevInputMetadata = $state<PrevInputMetadata>({
+		prev: [null, null, "none"],
+		willSyntheticBlur: false,
+	});
 	#pushPasswordManagerStrategy: PinInputRootStateProps["pushPasswordManagerStrategy"];
 	#pwmb: ReturnType<typeof usePasswordManagerBadge>;
 	#initialLoad: InitialLoad;
@@ -125,7 +131,7 @@ class PinInputRootState {
 					this.value.current = input.value;
 				}
 
-				this.#prevInputMetadata = [
+				this.#prevInputMetadata.prev = [
 					input.selectionStart,
 					input.selectionEnd,
 					input.selectionDirection ?? "none",
@@ -183,7 +189,7 @@ class PinInputRootState {
 				if (start !== null && end !== null) {
 					this.#mirrorSelectionStart = start;
 					this.#mirrorSelectionEnd = end;
-					this.#prevInputMetadata = [start, end, dir];
+					this.#prevInputMetadata.prev = [start, end, dir];
 				}
 			});
 		});
@@ -202,6 +208,30 @@ class PinInputRootState {
 		});
 	}
 
+	keysToIgnore = [
+		"Backspace",
+		"Delete",
+		"ArrowLeft",
+		"ArrowRight",
+		"ArrowUp",
+		"ArrowDown",
+		"Home",
+		"End",
+		"Escape",
+		"Enter",
+		"Tab",
+		"Shift",
+		"Control",
+	];
+
+	#onkeydown = (e: KeyboardEvent) => {
+		const key = e.key;
+		if (this.keysToIgnore.includes(key)) return;
+		if (key && this.#regexPattern && !this.#regexPattern.test(key)) {
+			e.preventDefault();
+		}
+	};
+
 	#rootStyles = $derived.by(() => ({
 		position: "relative",
 		cursor: this.#disabled.current ? "default" : "text",
@@ -304,7 +334,7 @@ class PinInputRootState {
 		const selDir = input.selectionDirection ?? "none";
 		const maxLength = input.maxLength;
 		const val = input.value;
-		const prev = this.#prevInputMetadata;
+		const prev = this.#prevInputMetadata.prev;
 
 		let start = -1;
 		let end = -1;
@@ -349,7 +379,7 @@ class PinInputRootState {
 		const dir = direction ?? selDir;
 		this.#mirrorSelectionStart = s;
 		this.#mirrorSelectionEnd = e;
-		this.#prevInputMetadata = [s, e, dir];
+		this.#prevInputMetadata.prev = [s, e, dir];
 	};
 
 	#oninput = (e: Event & { currentTarget: HTMLInputElement }) => {
@@ -387,11 +417,33 @@ class PinInputRootState {
 
 	#onpaste = (e: ClipboardEvent & { currentTarget: HTMLInputElement }) => {
 		const input = this.#inputRef.current;
+
+		if (!this.#initialLoad.isIOS) {
+			if (!e.clipboardData || !input) return;
+			const content = e.clipboardData.getData("text/plain");
+			const sanitizedContent = this.#onPaste?.current?.(content) ?? content;
+			if (
+				sanitizedContent.length > 0 &&
+				this.#regexPattern &&
+				!this.#regexPattern.test(sanitizedContent)
+			) {
+				e.preventDefault();
+				return;
+			}
+		}
+
 		if (!this.#initialLoad.isIOS || !e.clipboardData || !input) return;
 		const content = e.clipboardData.getData("text/plain");
 		e.preventDefault();
 
 		const sanitizedContent = this.#onPaste?.current?.(content) ?? content;
+		if (
+			sanitizedContent.length > 0 &&
+			this.#regexPattern &&
+			!this.#regexPattern.test(sanitizedContent)
+		) {
+			return;
+		}
 
 		const start = input.selectionStart === null ? undefined : input.selectionStart;
 		const end = input.selectionEnd === null ? undefined : input.selectionEnd;
@@ -430,6 +482,10 @@ class PinInputRootState {
 	};
 
 	#onblur = () => {
+		if (this.#prevInputMetadata.willSyntheticBlur) {
+			this.#prevInputMetadata.willSyntheticBlur = false;
+			return;
+		}
 		this.#isFocused.current = false;
 	};
 
@@ -448,6 +504,7 @@ class PinInputRootState {
 		//
 		onpaste: this.#onpaste,
 		oninput: this.#oninput,
+		onkeydown: this.#onkeydown,
 		onmouseover: this.#onmouseover,
 		onmouseleave: this.#onmouseleave,
 		onfocus: this.#onfocus,
diff --git a/packages/bits-ui/src/lib/bits/pin-input/usePasswordManager.svelte.ts b/packages/bits-ui/src/lib/bits/pin-input/usePasswordManager.svelte.ts
index 16217c5df..a790578c0 100644
--- a/packages/bits-ui/src/lib/bits/pin-input/usePasswordManager.svelte.ts
+++ b/packages/bits-ui/src/lib/bits/pin-input/usePasswordManager.svelte.ts
@@ -37,10 +37,10 @@ export function usePasswordManagerBadge({
 	let done = $state(false);
 
 	function willPushPwmBadge() {
-		const strat = pushPasswordManagerStrategy.current;
-		if (strat === "none") return false;
+		const strategy = pushPasswordManagerStrategy.current;
+		if (strategy === "none") return false;
 
-		const increaseWidthCase = strat === "increase-width" && hasPwmBadge && hasPwmBadgeSpace;
+		const increaseWidthCase = strategy === "increase-width" && hasPwmBadge && hasPwmBadgeSpace;
 
 		return increaseWidthCase;
 	}
@@ -62,11 +62,11 @@ export function usePasswordManagerBadge({
 		const y = centeredY;
 
 		// do an extra search to check for all the password manager badges
-		const pwms = document.querySelectorAll(PASSWORD_MANAGER_SELECTORS);
+		const passwordManagerStrategy = document.querySelectorAll(PASSWORD_MANAGER_SELECTORS);
 
-		// if no password manager is detected, dispatch document.elementfrompoint to
+		// if no password manager is detected, dispatch document.elementFromPoint to
 		// identify the badges
-		if (pwms.length === 0) {
+		if (passwordManagerStrategy.length === 0) {
 			const maybeBadgeEl = document.elementFromPoint(x, y);
 
 			// if the found element is the container,
diff --git a/packages/tests/src/tests/pin-input/pin-input.test.ts b/packages/tests/src/tests/pin-input/pin-input.test.ts
index 4fdffe43d..ad9bfe94d 100644
--- a/packages/tests/src/tests/pin-input/pin-input.test.ts
+++ b/packages/tests/src/tests/pin-input/pin-input.test.ts
@@ -1,7 +1,7 @@
 import { render, waitFor } from "@testing-library/svelte/svelte5";
 import { axe } from "jest-axe";
 import { describe, it, vi } from "vitest";
-import type { PinInput } from "bits-ui";
+import { type PinInput, REGEXP_ONLY_DIGITS } from "bits-ui";
 import { getTestKbd, setupUserEvents } from "../utils.js";
 import PinInputTest from "./pin-input-test.svelte";
 
@@ -153,4 +153,23 @@ describe("pin Input", () => {
 		expect(mockComplete).toHaveBeenCalledTimes(1);
 		expect(mockComplete).toHaveBeenCalledWith("123456");
 	});
+
+	it("should ignore keys that do not match the pattern", async () => {
+		const { user, hiddenInput } = setup({
+			pattern: REGEXP_ONLY_DIGITS,
+		});
+
+		await user.click(hiddenInput);
+		await user.keyboard("123");
+		expect(hiddenInput).toHaveValue("123");
+
+		await user.keyboard(kbd.BACKSPACE);
+		await user.keyboard(kbd.BACKSPACE);
+		await user.keyboard(kbd.BACKSPACE);
+		expect(hiddenInput).toHaveValue("");
+		await user.keyboard("$");
+		expect(hiddenInput).toHaveValue("");
+		await user.keyboard("1$");
+		expect(hiddenInput).toHaveValue("1");
+	});
 });
diff --git a/sites/docs/content/components/pin-input.md b/sites/docs/content/components/pin-input.md
index fb7749f3a..6fc50dda2 100644
--- a/sites/docs/content/components/pin-input.md
+++ b/sites/docs/content/components/pin-input.md
@@ -182,4 +182,28 @@ To submit the form when the input is complete, you can use the `onComplete` prop
 </form>
 ```
 
+## Patterns
+
+You can use the `pattern` prop to restrict the characters that can be entered or pasted into the input.
+
+<Callout type="warning" title="Note!">
+Client-side validation cannot replace server-side validation. Use this in addition to server-side validation for an improved user experience.
+</Callout>
+
+Bits UI exports a few common patterns that you can import and use in your application.
+
+-   `REGEXP_ONLY_DIGITS` - Only allow digits to be entered.
+-   `REGEXP_ONLY_CHARS` - Only allow characters to be entered.
+-   `REGEXP_ONLY_DIGITS_AND_CHARS` - Only allow digits and characters to be entered.
+
+```svelte
+<script lang="ts">
+	import { PinInput, REGEXP_ONLY_DIGITS } from "bits-ui";
+</script>
+
+<PinInput.Root pattern={REGEXP_ONLY_DIGITS}>
+	<!-- ... -->
+</PinInput.Root>
+```
+
 <APISection {schemas} />
diff --git a/sites/docs/src/lib/components/demos/pin-input-demo.svelte b/sites/docs/src/lib/components/demos/pin-input-demo.svelte
index 4b3637936..723aa63a4 100644
--- a/sites/docs/src/lib/components/demos/pin-input-demo.svelte
+++ b/sites/docs/src/lib/components/demos/pin-input-demo.svelte
@@ -1,5 +1,5 @@
 <script lang="ts">
-	import { PinInput, type PinInputRootSnippetProps } from "bits-ui";
+	import { PinInput, type PinInputRootSnippetProps, REGEXP_ONLY_DIGITS_AND_CHARS } from "bits-ui";
 	import { toast } from "svelte-sonner";
 	import { cn } from "$lib/utils/styles.js";
 
@@ -18,6 +18,7 @@
 	class="group/pininput flex items-center text-foreground has-[:disabled]:opacity-30"
 	maxlength={6}
 	{onComplete}
+	pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
 >
 	{#snippet children({ cells })}
 		<div class="flex">