diff --git a/docs/choosing-your-api.md b/docs/choosing-your-api.md
index 188821ce..587db4c8 100644
--- a/docs/choosing-your-api.md
+++ b/docs/choosing-your-api.md
@@ -33,6 +33,29 @@ The Imperative API is the foundation of TermUI. You work directly with classes a
- You need the **absolute lowest overhead** and maximum performance.
- You prefer **Object-Oriented Programming** and want to manage state in class properties.
+### Common use case: Custom widget
+
+When building a reusable widget (e.g., a progress bar) for others to import:
+
+```typescript
+import { Widget, Style } from '@termuijs/core'
+
+export class ProgressBar extends Widget {
+ private progress: number = 0
+
+ setProgress(value: number) {
+ this.progress = Math.max(0, Math.min(1, value))
+ this.markDirty() // Request re-render
+ }
+
+ render(buffer) {
+ const width = this.rect.width
+ const filled = Math.round(width * this.progress)
+ const bar = '█'.repeat(filled) + '░'.repeat(width - filled)
+ buffer.write(0, 0, bar, Style.fg('green'))
+ }
+}
+
### Example: Imperative Counter
```typescript
diff --git a/packages/widgets/src/Spinner.ts b/packages/widgets/src/Spinner.ts
new file mode 100644
index 00000000..ba257919
--- /dev/null
+++ b/packages/widgets/src/Spinner.ts
@@ -0,0 +1,32 @@
+import { useEffect, useState } from '@termuijs/jsx'
+import { caps } from '@termuijs/core'
+import { Text } from './Text'
+
+export interface SpinnerProps {
+ speed?: number
+ label?: string
+ color?: string
+}
+
+const FRAMES_UNICODE = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
+const FRAMES_ASCII = ['|', '/', '-', '\\']
+
+export function Spinner({ speed = 80, label = '', color = 'cyan' }: SpinnerProps) {
+ const [frame, setFrame] = useState(0)
+ const frames = caps.unicode ? FRAMES_UNICODE : FRAMES_ASCII
+
+ useEffect(() => {
+ if (caps.noMotion) return
+
+ const interval = setInterval(() => {
+ setFrame((f) => (f + 1) % frames.length)
+ }, speed)
+
+ return () => clearInterval(interval)
+ }, [speed, frames.length])
+
+ const spinnerChar = frames[frame]
+ const display = label ? `${spinnerChar} ${label}` : spinnerChar
+
+ return {display}
+}
\ No newline at end of file
diff --git a/packages/widgets/src/index.ts b/packages/widgets/src/index.ts
index 316ba500..87114204 100644
--- a/packages/widgets/src/index.ts
+++ b/packages/widgets/src/index.ts
@@ -310,3 +310,5 @@ export { UnorderedList } from './display/UnorderedList.js';
export type { UnorderedListOptions } from './display/UnorderedList.js';
export { Rule } from './display/Rule.js';
export type { RuleOrientation, RuleOptions } from './display/Rule.js';
+export { Spinner } from './Spinner'
+export type { SpinnerProps } from './Spinner'
\ No newline at end of file
diff --git a/tests/helpers/Spinner.test.ts b/tests/helpers/Spinner.test.ts
new file mode 100644
index 00000000..4cc30e7f
--- /dev/null
+++ b/tests/helpers/Spinner.test.ts
@@ -0,0 +1,11 @@
+import { describe, it, expect } from 'bun:test'
+import { render } from '@termuijs/testing'
+import { Spinner } from '../../packages/widgets/src/Spinner'
+
+describe('Spinner', () => {
+ it('renders with label', () => {
+ const t = render()
+ expect(t.renderToString()).toContain('Loading')
+ t.unmount()
+ })
+})
\ No newline at end of file