diff --git a/packages/ui/src/chips.tsx b/packages/ui/src/chips.tsx
new file mode 100644
index 0000000..88156fe
--- /dev/null
+++ b/packages/ui/src/chips.tsx
@@ -0,0 +1,27 @@
+/** Saved-views-lite: a pill row that filters a list by one dimension. */
+
+export interface ChipDef { key: string; label: string; count?: number }
+
+export function FilterChips(props: {
+ chips: ChipDef[]
+ active: string
+ onChange: (key: string) => void
+}) {
+ return (
+
+ {props.chips.map((c) => (
+
+ ))}
+
+ )
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 53bec6d..1d8139e 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -2,3 +2,6 @@ export * from './avatar'
export * from './components'
export * from './theme'
export * from './ThemeSwitcher'
+export * from './states'
+export * from './chips'
+export * from './tabs'
diff --git a/packages/ui/src/states.tsx b/packages/ui/src/states.tsx
new file mode 100644
index 0000000..093ef24
--- /dev/null
+++ b/packages/ui/src/states.tsx
@@ -0,0 +1,25 @@
+/** Loading + error surfaces — every page swaps its "Loading…" text for these. */
+
+const WIDTHS = ['92%', '70%', '84%', '55%', '78%']
+
+export function Skeleton(props: { rows?: number }) {
+ const rows = props.rows ?? 3
+ return (
+
+ {Array.from({ length: rows }, (_x, i) => (
+
+ ))}
+
+ )
+}
+
+export function ErrorState(props: { message: string; onRetry?: () => void }) {
+ return (
+
+ {props.message}
+ {props.onRetry !== undefined && (
+
+ )}
+
+ )
+}
diff --git a/packages/ui/src/tabs.tsx b/packages/ui/src/tabs.tsx
new file mode 100644
index 0000000..9576eb8
--- /dev/null
+++ b/packages/ui/src/tabs.tsx
@@ -0,0 +1,27 @@
+/** Underline tabs with optional mono counts (Direction A v2 pattern). */
+
+export interface TabDef { key: string; label: string; count?: number }
+
+export function Tabs(props: {
+ tabs: TabDef[]
+ active: string
+ onChange: (key: string) => void
+}) {
+ return (
+
+ {props.tabs.map((t) => (
+
+ ))}
+
+ )
+}