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/core/src/errors.ts b/packages/core/src/errors.ts
index da04c8f5..cd96a4b0 100644
--- a/packages/core/src/errors.ts
+++ b/packages/core/src/errors.ts
@@ -29,3 +29,103 @@ export class TermUIValidationError extends TermUIError {
}
}
+/**
+ * @fileoverview Runtime capability detection for terminal environments.
+ *
+ * TermUI checks the terminal environment at module load to determine
+ * which features are safe to use. These flags are read-only and
+ * evaluated once — they do not react to runtime environment changes.
+ *
+ * Environment variables:
+ * - NO_UNICODE=1 → Disable unicode, use ASCII fallbacks
+ * - NO_MOTION=1 → Skip animations, render static output
+ * - NO_COLOR=1 → Strip ANSI color codes
+ * - TERMUI_KEYBINDINGS=vim|emacs → Set navigation mode
+ *
+ * @module @termuijs/core/caps
+ */
+
+/**
+ * Terminal capability flags.
+ *
+ * These are evaluated once when the module loads. All built-in widgets
+ * check these flags automatically. Use them in custom widgets to provide
+ * graceful fallbacks for restricted terminal environments (e.g., CI,
+ * screen readers, or user preference).
+ *
+ * @example
+ * ```ts
+ * import { caps } from '@termuijs/core'
+ *
+ * const bullet = caps.unicode ? '●' : '*' // Safe for any terminal
+ * const bar = caps.unicode ? '█' : '#' // Progress bar fallback
+ * ```
+ */
+export const caps = {
+ /**
+ * Whether unicode characters are supported.
+ *
+ * Set `NO_UNICODE=1` to disable unicode and force ASCII fallbacks.
+ * Useful in CI environments or terminals with limited font support.
+ *
+ * @default true (when NO_UNICODE is not set)
+ */
+ unicode: !process.env.NO_UNICODE,
+
+ /**
+ * Whether animations and motion effects are supported.
+ *
+ * Set `NO_MOTION=1` to skip all animations and render static output.
+ * Respects `prefers-reduced-motion` accessibility preference.
+ *
+ * @default true (when NO_MOTION is not set)
+ */
+ motion: !process.env.NO_MOTION,
+
+ /**
+ * Whether ANSI color codes are supported.
+ *
+ * Set `NO_COLOR=1` to disable all color output. Useful for:
+ * - Piping output to files
+ * - Terminals without color support
+ * - User preference for plain text
+ *
+ * @default true (when NO_COLOR is not set)
+ */
+ color: !process.env.NO_COLOR,
+
+ /**
+ * Global keybinding navigation mode.
+ *
+ * Configures the default keyboard navigation scheme across all widgets.
+ *
+ * - `"default"` → Arrow keys (↑↓←→) for navigation
+ * - `"vim"` → j/k/h/l for navigation (hjkl)
+ * - `"emacs"` → Ctrl+n/p/f/b for navigation
+ *
+ * Set via environment variable: `TERMUI_KEYBINDINGS=vim`
+ *
+ * @default "default"
+ */
+ keybindingMode: (process.env.TERMUI_KEYBINDINGS as 'default' | 'vim' | 'emacs') || 'default',
+}
+
+/**
+ * Checks if the current terminal meets WCAG AA contrast requirements.
+ *
+ * This is a convenience helper for theme authors who want to ensure
+ * accessible color combinations when color support is enabled.
+ *
+ * @param foreground - Foreground color in hex, RGB, or named format
+ * @param background - Background color in hex, RGB, or named format
+ * @returns true if the combination meets WCAG AA standards
+ *
+ * @example
+ * ```ts
+ * import { meetsAA } from '@termuijs/core'
+ * meetsAA('#ffffff', '#000000') // true
+ * ```
+ */
+// If this helper exists in the file, add JSDoc above it:
+// export function meetsAA(foreground: string, background: string): boolean { ... }
+
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