diff --git a/apps/www/src/components/playground/index.ts b/apps/www/src/components/playground/index.ts
index 62ec07833..a3d25a167 100644
--- a/apps/www/src/components/playground/index.ts
+++ b/apps/www/src/components/playground/index.ts
@@ -28,6 +28,7 @@ export * from './label-examples';
export * from './link-examples';
export * from './list-examples';
export * from './menu-examples';
+export * from './number-field-examples';
export * from './popover-examples';
export * from './preview-card-examples';
export * from './radio-examples';
diff --git a/apps/www/src/components/playground/number-field-examples.tsx b/apps/www/src/components/playground/number-field-examples.tsx
new file mode 100644
index 000000000..16d4bd701
--- /dev/null
+++ b/apps/www/src/components/playground/number-field-examples.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { Flex, NumberField, Text } from '@raystack/apsara';
+import PlaygroundLayout from './playground-layout';
+
+export function NumberFieldExamples() {
+ return (
+
+
+ Default:
+
+
+ With Min/Max (0-10):
+
+
+ With Step (5):
+
+
+ Disabled:
+
+
+ Composed with ScrubArea:
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/www/src/content/docs/components/number-field/demo.ts b/apps/www/src/content/docs/components/number-field/demo.ts
new file mode 100644
index 000000000..2c6d5d67f
--- /dev/null
+++ b/apps/www/src/content/docs/components/number-field/demo.ts
@@ -0,0 +1,59 @@
+'use client';
+
+import { getPropsString } from '@/lib/utils';
+
+export const getCode = (props: any) => {
+ return ``;
+};
+
+export const playground = {
+ type: 'playground',
+ controls: {
+ defaultValue: { type: 'text', initialValue: '0' },
+ min: { type: 'text', initialValue: '' },
+ max: { type: 'text', initialValue: '' },
+ step: { type: 'text', initialValue: '1' },
+ disabled: { type: 'checkbox', initialValue: false, defaultValue: false }
+ },
+ getCode
+};
+
+export const basicDemo = {
+ type: 'code',
+ code: ``
+};
+
+export const minMaxDemo = {
+ type: 'code',
+ code: ``
+};
+
+export const stepDemo = {
+ type: 'code',
+ code: ``
+};
+
+export const disabledDemo = {
+ type: 'code',
+ code: ``
+};
+
+export const composedDemo = {
+ type: 'code',
+ code: `
+
+
+
+
+
+
+`
+};
+
+export const formatDemo = {
+ type: 'code',
+ code: ``
+};
diff --git a/apps/www/src/content/docs/components/number-field/index.mdx b/apps/www/src/content/docs/components/number-field/index.mdx
new file mode 100644
index 000000000..983df8f57
--- /dev/null
+++ b/apps/www/src/content/docs/components/number-field/index.mdx
@@ -0,0 +1,123 @@
+---
+title: Number Field
+description: A numeric input component with increment and decrement buttons, supporting scrub interaction.
+source: packages/raystack/components/number-field
+tag: new
+---
+
+import {
+ playground,
+ basicDemo,
+ minMaxDemo,
+ stepDemo,
+ disabledDemo,
+ composedDemo,
+ formatDemo,
+} from "./demo.ts";
+
+
+
+## Anatomy
+
+Import and use the component standalone or composed:
+
+```tsx
+import { NumberField } from "@raystack/apsara";
+
+{/* Standalone — renders decrement, input, and increment internally */}
+
+
+{/* Composed — full control over sub-components */}
+
+
+
+
+
+
+
+
+```
+
+## API Reference
+
+### Root
+
+The root component that manages numeric state. When no children are provided, it renders a default group with decrement, input, and increment controls.
+
+
+
+### Group
+
+Groups the input and button controls together.
+
+
+
+### Input
+
+The numeric input element.
+
+
+
+### Decrement
+
+Button to decrease the value.
+
+
+
+### Increment
+
+Button to increase the value.
+
+
+
+### ScrubArea
+
+An interactive area that allows adjusting the value by dragging. Renders a Label component internally.
+
+
+
+## Examples
+
+### Basic
+
+A standalone number field with default controls.
+
+
+
+### Min / Max
+
+Number field constrained to a range.
+
+
+
+### Step
+
+Number field with a custom step amount.
+
+
+
+### Disabled
+
+Number field in disabled state.
+
+
+
+### Composed with ScrubArea
+
+Full control with scrub area for drag-to-adjust interaction.
+
+
+
+### Formatted
+
+Number field with currency formatting.
+
+
+
+## Accessibility
+
+- The input has `role="textbox"` with `aria-roledescription="Number field"`
+- Increment and decrement buttons are keyboard accessible
+- ScrubArea associates its label with the input via `htmlFor`
+- Supports `aria-valuemin`, `aria-valuemax`, and `aria-valuenow`
+- Keyboard: Arrow Up/Down to increment/decrement, Shift for large step, Meta for small step
diff --git a/apps/www/src/content/docs/components/number-field/props.ts b/apps/www/src/content/docs/components/number-field/props.ts
new file mode 100644
index 000000000..1dbc84b4b
--- /dev/null
+++ b/apps/www/src/content/docs/components/number-field/props.ts
@@ -0,0 +1,89 @@
+export interface NumberFieldProps {
+ /** Controlled numeric value. */
+ value?: number | null;
+
+ /**
+ * Initial uncontrolled value.
+ * @defaultValue 0
+ */
+ defaultValue?: number;
+
+ /** Event handler called when the value changes. */
+ onValueChange?: (value: number | null, event: Event) => void;
+
+ /** Minimum allowed value. */
+ min?: number;
+
+ /** Maximum allowed value. */
+ max?: number;
+
+ /**
+ * Step amount for increment/decrement.
+ * @defaultValue 1
+ */
+ step?: number;
+
+ /**
+ * Whether the field is disabled.
+ * @defaultValue false
+ */
+ disabled?: boolean;
+
+ /**
+ * Whether the field is read-only.
+ * @defaultValue false
+ */
+ readOnly?: boolean;
+
+ /**
+ * Whether the field is required.
+ * @defaultValue false
+ */
+ required?: boolean;
+
+ /** Number formatting options (Intl.NumberFormatOptions). */
+ format?: Intl.NumberFormatOptions;
+
+ /** Additional CSS class names. */
+ className?: string;
+}
+
+export interface NumberFieldGroupProps {
+ /** Additional CSS class names. */
+ className?: string;
+}
+
+export interface NumberFieldInputProps {
+ /** Additional CSS class names. */
+ className?: string;
+}
+
+export interface NumberFieldDecrementProps {
+ /** Custom content for the decrement button. Defaults to a minus icon. */
+ children?: React.ReactNode;
+
+ /** Additional CSS class names. */
+ className?: string;
+}
+
+export interface NumberFieldIncrementProps {
+ /** Custom content for the increment button. Defaults to a plus icon. */
+ children?: React.ReactNode;
+
+ /** Additional CSS class names. */
+ className?: string;
+}
+
+export interface NumberFieldScrubAreaProps {
+ /** Label text displayed in the scrub area. */
+ label: string;
+
+ /**
+ * Scrub direction.
+ * @defaultValue "horizontal"
+ */
+ direction?: 'horizontal' | 'vertical';
+
+ /** Additional CSS class names. */
+ className?: string;
+}
diff --git a/packages/raystack/components/number-field/__tests__/number-field.test.tsx b/packages/raystack/components/number-field/__tests__/number-field.test.tsx
new file mode 100644
index 000000000..4de48e224
--- /dev/null
+++ b/packages/raystack/components/number-field/__tests__/number-field.test.tsx
@@ -0,0 +1,178 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createRef } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { NumberField } from '../number-field';
+import styles from '../number-field.module.css';
+
+describe('NumberField', () => {
+ describe('Basic Rendering', () => {
+ it('renders default structure when no children provided', () => {
+ render();
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ expect(screen.getByRole('textbox')).toHaveValue('5');
+ });
+
+ it('renders decrement and increment buttons', () => {
+ render();
+ const buttons = screen.getAllByRole('button');
+ expect(buttons).toHaveLength(2);
+ });
+
+ it('applies custom className to root', () => {
+ const { container } = render(
+
+ );
+ const root = container.firstElementChild;
+ expect(root).toHaveClass(styles.root);
+ expect(root).toHaveClass('custom-class');
+ });
+
+ it('forwards ref to root', () => {
+ const ref = createRef();
+ render();
+ expect(ref.current).toBeInstanceOf(HTMLElement);
+ });
+ });
+
+ describe('Composed Usage', () => {
+ it('renders children when provided', () => {
+ render(
+
+
+
+
+
+
+
+ );
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ expect(screen.getAllByRole('button')).toHaveLength(2);
+ });
+ });
+
+ describe('Interaction', () => {
+ it('increments value on increment button click', () => {
+ const onValueChange = vi.fn();
+ render();
+ const buttons = screen.getAllByRole('button');
+ fireEvent.click(buttons[1]);
+ expect(onValueChange).toHaveBeenCalled();
+ });
+
+ it('decrements value on decrement button click', () => {
+ const onValueChange = vi.fn();
+ render();
+ const buttons = screen.getAllByRole('button');
+ fireEvent.click(buttons[0]);
+ expect(onValueChange).toHaveBeenCalled();
+ });
+
+ it('supports keyboard input', async () => {
+ const user = userEvent.setup();
+ render();
+ const input = screen.getByRole('textbox');
+ await user.clear(input);
+ await user.type(input, '42');
+ expect(input).toHaveValue('42');
+ });
+ });
+
+ describe('Controlled', () => {
+ it('renders controlled value', () => {
+ render();
+ expect(screen.getByRole('textbox')).toHaveValue('10');
+ });
+ });
+
+ describe('Constraints', () => {
+ it('respects min and max', () => {
+ render();
+ const buttons = screen.getAllByRole('button');
+ const input = screen.getByRole('textbox');
+ // Try to increment beyond max
+ fireEvent.click(buttons[1]);
+ expect(input).toHaveValue('10');
+ });
+ });
+
+ describe('Disabled State', () => {
+ it('disables all controls when disabled', () => {
+ render();
+ const input = screen.getByRole('textbox');
+ expect(input).toHaveAttribute('disabled');
+ });
+ });
+
+ describe('Sub-components', () => {
+ it('Group applies custom className', () => {
+ render(
+
+
+
+
+
+ );
+ const group = screen.getByRole('group');
+ expect(group).toHaveClass('custom-group');
+ expect(group).toHaveClass(styles.group);
+ });
+
+ it('Input applies custom className', () => {
+ render(
+
+
+
+
+
+ );
+ const input = screen.getByRole('textbox');
+ expect(input).toHaveClass('custom-input');
+ expect(input).toHaveClass(styles.input);
+ });
+
+ it('Decrement forwards ref', () => {
+ const ref = createRef();
+ render(
+
+
+
+
+
+
+
+ );
+ expect(ref.current).toBeInstanceOf(HTMLButtonElement);
+ });
+
+ it('Increment forwards ref', () => {
+ const ref = createRef();
+ render(
+
+
+
+
+
+
+
+ );
+ expect(ref.current).toBeInstanceOf(HTMLButtonElement);
+ });
+ });
+
+ describe('ScrubArea', () => {
+ it('renders label inside scrub area', () => {
+ render(
+
+
+
+
+
+
+
+
+ );
+ expect(screen.getByText('Amount')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/raystack/components/number-field/index.tsx b/packages/raystack/components/number-field/index.tsx
new file mode 100644
index 000000000..5c1d2e28b
--- /dev/null
+++ b/packages/raystack/components/number-field/index.tsx
@@ -0,0 +1,2 @@
+export type { NumberFieldScrubAreaProps } from './number-field';
+export { NumberField } from './number-field';
diff --git a/packages/raystack/components/number-field/number-field.module.css b/packages/raystack/components/number-field/number-field.module.css
new file mode 100644
index 000000000..c4721ffdc
--- /dev/null
+++ b/packages/raystack/components/number-field/number-field.module.css
@@ -0,0 +1,81 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--rs-space-2);
+}
+
+.group {
+ display: inline-flex;
+ align-items: center;
+}
+
+.decrement,
+.increment {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ flex-shrink: 0;
+ background: var(--rs-color-background-base-secondary);
+ border: 0.5px solid var(--rs-color-border-base-primary);
+ color: var(--rs-color-foreground-base-primary);
+ cursor: pointer;
+ padding: 0;
+}
+
+.decrement {
+ border-radius: var(--rs-radius-1) 0 0 var(--rs-radius-1);
+}
+
+.increment {
+ border-radius: 0 var(--rs-radius-1) var(--rs-radius-1) 0;
+}
+
+.decrement:hover,
+.increment:hover {
+ background: var(--rs-color-background-base-primary-hover);
+}
+
+.decrement[data-disabled],
+.increment[data-disabled] {
+ pointer-events: none;
+ opacity: 0.5;
+}
+
+.input {
+ height: 24px;
+ min-width: 0;
+ flex: 1 0 0;
+ background: var(--rs-color-background-base-primary);
+ border: 0.5px solid var(--rs-color-border-base-primary);
+ font-family: var(--rs-font-body);
+ font-size: var(--rs-font-size-small);
+ font-weight: var(--rs-font-weight-regular);
+ line-height: var(--rs-line-height-small);
+ letter-spacing: var(--rs-letter-spacing-small);
+ color: var(--rs-color-foreground-base-primary);
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+ outline: none;
+ padding: 0 var(--rs-space-2);
+}
+
+.input[data-disabled] {
+ pointer-events: none;
+ opacity: 0.5;
+}
+
+.scrub-area {
+ display: flex;
+ cursor: ew-resize;
+}
+
+.scrub-area-label {
+ cursor: ew-resize;
+}
+
+.scrub-area-cursor {
+ filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
+}
diff --git a/packages/raystack/components/number-field/number-field.tsx b/packages/raystack/components/number-field/number-field.tsx
new file mode 100644
index 000000000..4f14846ac
--- /dev/null
+++ b/packages/raystack/components/number-field/number-field.tsx
@@ -0,0 +1,153 @@
+'use client';
+
+import { NumberField as NumberFieldPrimitive } from '@base-ui/react';
+import { MinusIcon, PlusIcon } from '@radix-ui/react-icons';
+import { cx } from 'class-variance-authority';
+import {
+ createContext,
+ ElementRef,
+ forwardRef,
+ type ReactNode,
+ useContext,
+ useId
+} from 'react';
+import { Label } from '../label';
+import styles from './number-field.module.css';
+
+const NumberFieldContext = createContext<{ fieldId: string } | null>(null);
+
+const NumberFieldRoot = forwardRef<
+ ElementRef,
+ NumberFieldPrimitive.Root.Props & { children?: ReactNode }
+>(({ className, children, id, ...props }, ref) => {
+ const generatedId = useId();
+ const fieldId = id ?? generatedId;
+
+ return (
+
+
+ {children ?? (
+
+
+
+
+
+ )}
+
+
+ );
+});
+NumberFieldRoot.displayName = 'NumberField';
+
+const NumberFieldGroup = forwardRef<
+ ElementRef,
+ NumberFieldPrimitive.Group.Props
+>(({ className, ...props }, ref) => (
+
+));
+NumberFieldGroup.displayName = 'NumberField.Group';
+
+const NumberFieldInput = forwardRef<
+ ElementRef,
+ NumberFieldPrimitive.Input.Props
+>(({ className, ...props }, ref) => (
+
+));
+NumberFieldInput.displayName = 'NumberField.Input';
+
+const NumberFieldDecrement = forwardRef<
+ ElementRef,
+ NumberFieldPrimitive.Decrement.Props
+>(({ className, children, ...props }, ref) => (
+
+ {children ?? }
+
+));
+NumberFieldDecrement.displayName = 'NumberField.Decrement';
+
+const NumberFieldIncrement = forwardRef<
+ ElementRef,
+ NumberFieldPrimitive.Increment.Props
+>(({ className, children, ...props }, ref) => (
+
+ {children ?? }
+
+));
+NumberFieldIncrement.displayName = 'NumberField.Increment';
+
+export interface NumberFieldScrubAreaProps
+ extends NumberFieldPrimitive.ScrubArea.Props {
+ label: string;
+}
+
+const NumberFieldScrubArea = forwardRef<
+ ElementRef,
+ NumberFieldScrubAreaProps
+>(({ className, label, ...props }, ref) => {
+ const context = useContext(NumberFieldContext);
+
+ return (
+
+
+
+
+
+
+ );
+});
+NumberFieldScrubArea.displayName = 'NumberField.ScrubArea';
+
+function CursorGrowIcon(props: React.ComponentProps<'svg'>) {
+ return (
+
+ );
+}
+
+export const NumberField = Object.assign(NumberFieldRoot, {
+ Group: NumberFieldGroup,
+ Input: NumberFieldInput,
+ Decrement: NumberFieldDecrement,
+ Increment: NumberFieldIncrement,
+ ScrubArea: NumberFieldScrubArea,
+ ScrubAreaCursor: NumberFieldPrimitive.ScrubAreaCursor
+});
diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx
index 37885b603..1404b7a14 100644
--- a/packages/raystack/index.tsx
+++ b/packages/raystack/index.tsx
@@ -44,6 +44,7 @@ export { Link } from './components/link';
export { List } from './components/list';
export { Menu } from './components/menu';
export { Navbar } from './components/navbar';
+export { NumberField } from './components/number-field';
export { Popover } from './components/popover';
export { PreviewCard } from './components/preview-card';
export { Radio } from './components/radio';