@seekalfred/primitives 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/README.md +664 -9
  2. package/dist/accordion/accordion.d.ts +5 -5
  3. package/dist/accordion/accordion.js +22 -11
  4. package/dist/accordion/accordion.js.map +1 -1
  5. package/dist/action-button/action-button.d.ts +50 -0
  6. package/dist/action-button/action-button.js +159 -0
  7. package/dist/action-button/action-button.js.map +1 -0
  8. package/dist/action-button/index.d.ts +2 -0
  9. package/dist/action-button/index.js +2 -0
  10. package/dist/alert/alert.d.ts +16 -2
  11. package/dist/alert/alert.js +118 -20
  12. package/dist/alert/alert.js.map +1 -1
  13. package/dist/alert-dialog/alert-dialog.d.ts +1 -1
  14. package/dist/alert-dialog/alert-dialog.js +4 -2
  15. package/dist/alert-dialog/alert-dialog.js.map +1 -1
  16. package/dist/avatar/avatar.d.ts +7 -1
  17. package/dist/avatar/avatar.js +51 -18
  18. package/dist/avatar/avatar.js.map +1 -1
  19. package/dist/badge/badge.d.ts +10 -3
  20. package/dist/badge/badge.js +37 -5
  21. package/dist/badge/badge.js.map +1 -1
  22. package/dist/breadcrumb/breadcrumb.d.ts +3 -30
  23. package/dist/breadcrumb/breadcrumb.js +25 -8
  24. package/dist/breadcrumb/breadcrumb.js.map +1 -1
  25. package/dist/button/button.d.ts +36 -23
  26. package/dist/button/button.js +178 -33
  27. package/dist/button/button.js.map +1 -1
  28. package/dist/button-group/button-group.d.ts +76 -0
  29. package/dist/button-group/button-group.js +403 -0
  30. package/dist/button-group/button-group.js.map +1 -0
  31. package/dist/button-group/index.d.ts +2 -0
  32. package/dist/button-group/index.js +2 -0
  33. package/dist/calendar/calendar.d.ts +3 -3
  34. package/dist/calendar/calendar.js +263 -34
  35. package/dist/calendar/calendar.js.map +1 -1
  36. package/dist/card/card.d.ts +14 -7
  37. package/dist/card/card.js +32 -14
  38. package/dist/card/card.js.map +1 -1
  39. package/dist/checkbox/checkbox.d.ts +2 -9
  40. package/dist/checkbox/checkbox.js +28 -29
  41. package/dist/checkbox/checkbox.js.map +1 -1
  42. package/dist/confirm-morph/confirm-morph.d.ts +85 -0
  43. package/dist/confirm-morph/confirm-morph.js +652 -0
  44. package/dist/confirm-morph/confirm-morph.js.map +1 -0
  45. package/dist/confirm-morph/index.d.ts +2 -0
  46. package/dist/confirm-morph/index.js +2 -0
  47. package/dist/copy-button/copy-button.d.ts +32 -0
  48. package/dist/copy-button/copy-button.js +134 -0
  49. package/dist/copy-button/copy-button.js.map +1 -0
  50. package/dist/copy-button/index.d.ts +2 -0
  51. package/dist/copy-button/index.js +2 -0
  52. package/dist/date-picker/date-picker.d.ts +51 -0
  53. package/dist/date-picker/date-picker.js +241 -0
  54. package/dist/date-picker/date-picker.js.map +1 -0
  55. package/dist/date-picker/date-range-picker.d.ts +78 -0
  56. package/dist/date-picker/date-range-picker.js +1015 -0
  57. package/dist/date-picker/date-range-picker.js.map +1 -0
  58. package/dist/date-picker/index.d.ts +4 -0
  59. package/dist/date-picker/index.js +4 -0
  60. package/dist/date-picker/picker-parts.d.ts +46 -0
  61. package/dist/date-picker/picker-parts.js +135 -0
  62. package/dist/date-picker/picker-parts.js.map +1 -0
  63. package/dist/dialog/dialog.d.ts +12 -16
  64. package/dist/dialog/dialog.js +45 -14
  65. package/dist/dialog/dialog.js.map +1 -1
  66. package/dist/empty-state/empty-state.d.ts +44 -0
  67. package/dist/empty-state/empty-state.js +83 -0
  68. package/dist/empty-state/empty-state.js.map +1 -0
  69. package/dist/empty-state/index.d.ts +2 -0
  70. package/dist/empty-state/index.js +2 -0
  71. package/dist/expanding-button-group/expanding-button-group.d.ts +54 -0
  72. package/dist/expanding-button-group/expanding-button-group.js +350 -0
  73. package/dist/expanding-button-group/expanding-button-group.js.map +1 -0
  74. package/dist/expanding-button-group/index.d.ts +2 -0
  75. package/dist/expanding-button-group/index.js +2 -0
  76. package/dist/file-drop-zone/file-drop-zone.d.ts +71 -0
  77. package/dist/file-drop-zone/file-drop-zone.js +116 -0
  78. package/dist/file-drop-zone/file-drop-zone.js.map +1 -0
  79. package/dist/file-drop-zone/index.d.ts +2 -0
  80. package/dist/file-drop-zone/index.js +2 -0
  81. package/dist/filter-bar/filter-bar.d.ts +121 -0
  82. package/dist/filter-bar/filter-bar.js +300 -0
  83. package/dist/filter-bar/filter-bar.js.map +1 -0
  84. package/dist/filter-bar/index.d.ts +2 -0
  85. package/dist/filter-bar/index.js +2 -0
  86. package/dist/floating-button-group/floating-button-group.d.ts +61 -0
  87. package/dist/floating-button-group/floating-button-group.js +340 -0
  88. package/dist/floating-button-group/floating-button-group.js.map +1 -0
  89. package/dist/floating-button-group/index.d.ts +2 -0
  90. package/dist/floating-button-group/index.js +2 -0
  91. package/dist/icon-morph/icon-morph.d.ts +55 -0
  92. package/dist/icon-morph/icon-morph.js +331 -0
  93. package/dist/icon-morph/icon-morph.js.map +1 -0
  94. package/dist/icon-morph/index.d.ts +2 -0
  95. package/dist/icon-morph/index.js +2 -0
  96. package/dist/index.d.ts +24 -2
  97. package/dist/index.js +29 -7
  98. package/dist/input/input.d.ts +23 -1
  99. package/dist/input/input.js +37 -13
  100. package/dist/input/input.js.map +1 -1
  101. package/dist/label/label.d.ts +1 -1
  102. package/dist/label/label.js +4 -2
  103. package/dist/label/label.js.map +1 -1
  104. package/dist/lib/count-to.js +113 -0
  105. package/dist/lib/count-to.js.map +1 -0
  106. package/dist/lib/eye-morph.js +62 -0
  107. package/dist/lib/eye-morph.js.map +1 -0
  108. package/dist/lib/field-message.js +336 -0
  109. package/dist/lib/field-message.js.map +1 -0
  110. package/dist/lib/field-shell.js +81 -0
  111. package/dist/lib/field-shell.js.map +1 -0
  112. package/dist/lib/field-surface.js +4 -2
  113. package/dist/lib/field-surface.js.map +1 -1
  114. package/dist/lib/flip.js +80 -0
  115. package/dist/lib/flip.js.map +1 -0
  116. package/dist/lib/height-follow.js +59 -0
  117. package/dist/lib/height-follow.js.map +1 -0
  118. package/dist/lib/icon-swap.js +47 -0
  119. package/dist/lib/icon-swap.js.map +1 -0
  120. package/dist/lib/leaving-list.js +57 -0
  121. package/dist/lib/leaving-list.js.map +1 -0
  122. package/dist/lib/merge-refs.js +22 -0
  123. package/dist/lib/merge-refs.js.map +1 -0
  124. package/dist/lib/morph-text.js +128 -0
  125. package/dist/lib/morph-text.js.map +1 -0
  126. package/dist/lib/motion.js +162 -0
  127. package/dist/lib/motion.js.map +1 -0
  128. package/dist/lib/pausable-timeout.js +67 -0
  129. package/dist/lib/pausable-timeout.js.map +1 -0
  130. package/dist/lib/rolling-value.js +31 -0
  131. package/dist/lib/rolling-value.js.map +1 -0
  132. package/dist/lib/spring-progress.js +114 -0
  133. package/dist/lib/spring-progress.js.map +1 -0
  134. package/dist/lib/svg-id.js +10 -0
  135. package/dist/lib/svg-id.js.map +1 -0
  136. package/dist/lib/swap-text.js +77 -0
  137. package/dist/lib/swap-text.js.map +1 -0
  138. package/dist/lib/use-copy-feedback.js +36 -0
  139. package/dist/lib/use-copy-feedback.js.map +1 -0
  140. package/dist/menu/menu.d.ts +5 -5
  141. package/dist/menu/menu.js +10 -6
  142. package/dist/menu/menu.js.map +1 -1
  143. package/dist/page-header/index.d.ts +2 -0
  144. package/dist/page-header/index.js +2 -0
  145. package/dist/page-header/page-header.d.ts +75 -0
  146. package/dist/page-header/page-header.js +83 -0
  147. package/dist/page-header/page-header.js.map +1 -0
  148. package/dist/password-field/index.d.ts +2 -0
  149. package/dist/password-field/index.js +2 -0
  150. package/dist/password-field/password-field.d.ts +37 -0
  151. package/dist/password-field/password-field.js +101 -0
  152. package/dist/password-field/password-field.js.map +1 -0
  153. package/dist/password-strength/index.d.ts +2 -0
  154. package/dist/password-strength/index.js +2 -0
  155. package/dist/password-strength/password-strength.d.ts +127 -0
  156. package/dist/password-strength/password-strength.js +455 -0
  157. package/dist/password-strength/password-strength.js.map +1 -0
  158. package/dist/popover/popover.d.ts +10 -2
  159. package/dist/popover/popover.js +6 -4
  160. package/dist/popover/popover.js.map +1 -1
  161. package/dist/progress/progress.d.ts +2 -2
  162. package/dist/progress/progress.js +50 -7
  163. package/dist/progress/progress.js.map +1 -1
  164. package/dist/radio-group/radio-group.d.ts +3 -3
  165. package/dist/radio-group/radio-group.js +14 -10
  166. package/dist/radio-group/radio-group.js.map +1 -1
  167. package/dist/scroll-area/scroll-area.d.ts +17 -6
  168. package/dist/scroll-area/scroll-area.js +30 -6
  169. package/dist/scroll-area/scroll-area.js.map +1 -1
  170. package/dist/search-field/index.d.ts +2 -0
  171. package/dist/search-field/index.js +2 -0
  172. package/dist/search-field/search-field.d.ts +26 -0
  173. package/dist/search-field/search-field.js +155 -0
  174. package/dist/search-field/search-field.js.map +1 -0
  175. package/dist/select/select.d.ts +3 -3
  176. package/dist/select/select.js +85 -24
  177. package/dist/select/select.js.map +1 -1
  178. package/dist/separator/separator.d.ts +1 -1
  179. package/dist/separator/separator.js +4 -2
  180. package/dist/separator/separator.js.map +1 -1
  181. package/dist/sheet/sheet.d.ts +1 -1
  182. package/dist/sheet/sheet.js +6 -3
  183. package/dist/sheet/sheet.js.map +1 -1
  184. package/dist/side-nav/index.d.ts +2 -0
  185. package/dist/side-nav/index.js +2 -0
  186. package/dist/side-nav/side-nav.d.ts +72 -0
  187. package/dist/side-nav/side-nav.js +142 -0
  188. package/dist/side-nav/side-nav.js.map +1 -0
  189. package/dist/skeleton/skeleton.d.ts +8 -2
  190. package/dist/skeleton/skeleton.js +9 -3
  191. package/dist/skeleton/skeleton.js.map +1 -1
  192. package/dist/slider/slider.d.ts +2 -2
  193. package/dist/slider/slider.js +64 -8
  194. package/dist/slider/slider.js.map +1 -1
  195. package/dist/spinner/spinner.d.ts +1 -1
  196. package/dist/spinner/spinner.js +4 -2
  197. package/dist/spinner/spinner.js.map +1 -1
  198. package/dist/split-button/index.d.ts +2 -0
  199. package/dist/split-button/index.js +2 -0
  200. package/dist/split-button/split-button.d.ts +46 -0
  201. package/dist/split-button/split-button.js +122 -0
  202. package/dist/split-button/split-button.js.map +1 -0
  203. package/dist/stat-card/index.d.ts +2 -0
  204. package/dist/stat-card/index.js +2 -0
  205. package/dist/stat-card/stat-card.d.ts +81 -0
  206. package/dist/stat-card/stat-card.js +152 -0
  207. package/dist/stat-card/stat-card.js.map +1 -0
  208. package/dist/styles.css +1 -1
  209. package/dist/switch/switch.d.ts +2 -2
  210. package/dist/switch/switch.js +11 -8
  211. package/dist/switch/switch.js.map +1 -1
  212. package/dist/table/index.d.ts +2 -2
  213. package/dist/table/index.js +2 -2
  214. package/dist/table/table.d.ts +38 -7
  215. package/dist/table/table.js +114 -12
  216. package/dist/table/table.js.map +1 -1
  217. package/dist/tabs/tabs.d.ts +4 -4
  218. package/dist/tabs/tabs.js +153 -12
  219. package/dist/tabs/tabs.js.map +1 -1
  220. package/dist/textarea/textarea.d.ts +24 -1
  221. package/dist/textarea/textarea.js +27 -3
  222. package/dist/textarea/textarea.js.map +1 -1
  223. package/dist/theme-switch/index.d.ts +2 -0
  224. package/dist/theme-switch/index.js +2 -0
  225. package/dist/theme-switch/theme-switch.d.ts +37 -0
  226. package/dist/theme-switch/theme-switch.js +123 -0
  227. package/dist/theme-switch/theme-switch.js.map +1 -0
  228. package/dist/time-picker/index.d.ts +2 -0
  229. package/dist/time-picker/index.js +2 -0
  230. package/dist/time-picker/time-picker.d.ts +54 -0
  231. package/dist/time-picker/time-picker.js +201 -0
  232. package/dist/time-picker/time-picker.js.map +1 -0
  233. package/dist/toast/toast.d.ts +2 -2
  234. package/dist/toast/toast.js +61 -39
  235. package/dist/toast/toast.js.map +1 -1
  236. package/dist/tooltip/tooltip.d.ts +3 -3
  237. package/dist/tooltip/tooltip.js +66 -15
  238. package/dist/tooltip/tooltip.js.map +1 -1
  239. package/package.json +83 -3
package/README.md CHANGED
@@ -61,6 +61,29 @@ Two deliberate behaviour differences:
61
61
  `hidden` attribute (`display: none`), which CSS cannot transition out of.
62
62
  The chevron still rotates.
63
63
 
64
+ ### Motion (Accordion)
65
+
66
+ A panel opens and closes in place with plain CSS: height follows the content on the smooth spring, opacity eases over 240ms on the way open and 160ms on the way shut, and the answer settles down from 6px up with a little blur. The chevron turns on the snappy spring. React Aria marks a collapsed panel `hidden`, which cannot transition, so the CSS keeps it as a box of no height and takes it out of the accessibility tree with `content-visibility: hidden`. Height needs `interpolate-size` (Chromium); elsewhere it snaps open, still correct. The panel's children now sit in an `accordion-panel-content` wrapper. Nothing animates at mount; reduced motion is instant.
67
+
68
+ ## ActionButton
69
+
70
+ ```tsx
71
+ <ActionButton onAction={save} pendingLabel="Saving" successLabel="Saved">
72
+ Save changes
73
+ </ActionButton>
74
+ ```
75
+
76
+ A button for one async commit. Its label morphs letter by letter, from the
77
+ resting text to a pending one to a success one: letters both share keep their
78
+ place, new ones rise in from a soft blur, removed ones lift away, and the width
79
+ springs to fit. The arrow becomes a spinner and then a tick that draws itself.
80
+
81
+ `onAction` is awaited. A rejection returns the button to rest and goes to
82
+ `onActionError` (without it the error is dropped, because the button has
83
+ already returned to rest). It stays focusable while pending (`aria-disabled`,
84
+ `aria-busy`), a live region announces the pending and success labels, and the
85
+ accessible name is always the resting label. `variant` and `size` are Button's.
86
+
64
87
  ## Alert
65
88
 
66
89
  ```tsx
@@ -86,6 +109,10 @@ No `success` / `warning` / `info` variants yet — one faked call site is b
86
109
  the Rule of Two. Until then, a `className` override is the supported route;
87
110
  consumer CSS always wins.
88
111
 
112
+ ### Motion (Alert)
113
+
114
+ A string `title` or description swaps as a whole when its text changes (the new text rises in, the old lifts away); a different `icon` grows in from small and blurred; the copy column springs to its new height. New optional `open` and `onDismiss` props make the alert come and go: hiding it collapses its height with the fade (the spring, opacity 240ms in and 160ms out), and `onDismiss` adds a Dismiss button (`dismissLabel`). An uncontrolled alert (`onDismiss` without `open`) collapses first and then reports; with `open`, the owner decides. An alert open on first render does not animate, and one with neither prop is the same DOM as before.
115
+
89
116
  ## AlertDialog
90
117
 
91
118
  ```tsx
@@ -138,6 +165,10 @@ Sizes: `xs` (20px) · `sm` (24px) · `md` (32px, default) · `lg` (40px)
138
165
  | `className="h-10 w-10"` | `size="lg"` | six ad-hoc sizes became five named ones |
139
166
  | `<AvatarImage src />` with no `alt` | `alt` is **required** | `""` for decorative; 15 of 38 legacy uses had none, which fails axe |
140
167
 
168
+ ### Motion (Avatar)
169
+
170
+ A photo that is still downloading after mount fades in and un-blurs over 240ms; one already decoded shows at once. The initials fade in over 240ms while a photo loads or after it fails. New optional `presence` (`"online" | "offline"`) draws a dot on the corner that grows in on the snappy spring, swaps when it changes and shrinks away; its word is read beside the name. The root no longer clips (the dot sits on its edge); the photo clips itself to the circle.
171
+
141
172
  ## Badge
142
173
 
143
174
  ```tsx
@@ -184,6 +215,10 @@ the product's `--primary-*` scale, value for value — `periwinkle.50` is
184
215
  No `asChild` — zero call sites, and the legacy `[a&]:hover:` rules existed only
185
216
  for the anchor it produced.
186
217
 
218
+ ### Motion (Badge)
219
+
220
+ A new string label swaps as a whole and the pill's width springs to the new size on the morph spring; colour, border and fill ease over 240ms. New optional `icon` swaps in place when it changes. The label now sits in a wrapper inside the badge, so query the root through `[data-slot="badge"]`, not through its text. The reference's hover tint is not ported.
221
+
187
222
  ## Breadcrumbs
188
223
 
189
224
  ```tsx
@@ -224,6 +259,11 @@ Two smaller deliberate changes: the separator is `/` rather than a lucide
224
259
  separators were the default), and hovering a crumb underlines it as well as
225
260
  darkening it — colour alone disappears under forced-colors.
226
261
 
262
+
263
+ ### Motion
264
+
265
+ A crumb added after the first render slides in 8px from the left over 240ms and the crumbs that stay slide to their new place; link colour eases over 160ms. The trail present at first render is still. A removed crumb disappears at once (it would need a leaving copy), and the reference's underline fade is not ported: Alfred's links carry no underline at rest by design.
266
+
227
267
  ## Button
228
268
 
229
269
  | variant | |
@@ -246,17 +286,54 @@ Sizes: `sm` (32px) · `md` (36px, default) · `lg` (40px) · `icon` (36px
246
286
  one it has no accessible name and fails axe's `button-name` rule — the
247
287
  `IconOnly` story exists to keep that true.
248
288
 
249
- `buttonVariants` is exported. Use it when you need button styling on an element
250
- this component cannot render:
289
+ Give it an `href` and it is a link: a real `<a>`, announced as a link, with the
290
+ same classes and the same hover, press and focus-ring states. Use it whenever
291
+ activating the control navigates.
251
292
 
252
293
  ```tsx
253
- import { buttonVariants } from "@seekalfred/primitives";
254
-
255
- <a href="/reports" className={buttonVariants({ variant: "outline" })}>
294
+ <Button href="/reports" variant="outlineNeutral">
256
295
  View reports
257
- </a>;
296
+ </Button>
258
297
  ```
259
298
 
299
+ For client-side routing, mount React Aria's `RouterProvider` once above it;
300
+ without one an `href` is an ordinary page load, which is what an external or
301
+ download link wants anyway. `target`, `rel` and `download` pass through.
302
+
303
+ `buttonVariants` is still exported, but it is no longer the way to style a
304
+ link. On your own `<a>` its hover, press and focus classes never apply: they key
305
+ on React Aria data attributes a plain anchor never receives, so keyboard focus
306
+ is invisible.
307
+
308
+ ### Motion
309
+
310
+ A Button moves, and every motion is pure CSS plus a little plain React where CSS
311
+ cannot reach (no animation library):
312
+
313
+ - **Press.** It scales down while pressed (0.97; 0.96 for an icon button; 0.985
314
+ above 220px wide) and springs back. Menu and popover triggers
315
+ (`aria-haspopup`, `role="combobox"`, `data-state`), disabled and pending
316
+ buttons never scale.
317
+ - **A changing label.** A different text, or a different icon, crossfades: the
318
+ old one is popped out of flow where it was, `aria-hidden`, and lifts away
319
+ while the new one rises in, and the width springs to fit. Nothing animates on
320
+ first render. While it runs the OUTGOING label is still in the DOM, so read a
321
+ button's text after the change settles, and keep `id`s, inputs and anything
322
+ stateful out of a label that changes.
323
+ - **`isPending`.** A spinner scales in over a label that fades; the button
324
+ stays focusable (`aria-disabled`) and announces itself (`aria-busy`).
325
+ - **Hover and press** on `primary` step its opacity (0.91, 0.84) and every
326
+ filled and outlined variant gains a resting shadow on hover. A disabled
327
+ `primary` fades; the other variants stay opaque and mute their colours.
328
+
329
+ Reduced motion keeps the opacity change and drops the movement and blur. The
330
+ spring easings are `linear()` curves (Chrome 113, Safari 17.2, Firefox 112);
331
+ older browsers get a cubic-bezier with the same overshoot.
332
+
333
+ The `@seekalfred/styles` motion tokens behind it are `--ease-*` and
334
+ `--duration-*` (`settle`, `enter`, `snappy`, `morph`, `gentle`, `calm`,
335
+ `smooth`, `quick`, `instant`, …).
336
+
260
337
  ## Migrating from the legacy shadcn Button
261
338
 
262
339
  The variant and size vocabulary is unchanged, so most call sites move by
@@ -269,7 +346,7 @@ React Aria Components rather than a bare `<button>`:
269
346
  | `disabled` | `isDisabled` | |
270
347
  | `variant="default"` | `variant="primary"` | `default` named nothing. |
271
348
  | `size="default"` | `size="md"` | Same 36px height. |
272
- | `<Button asChild><a /></Button>` | `<a className={buttonVariants({ variant })}>` | React Aria's Button never renders an anchor. A real `Link` primitive lands in Phase 1. |
349
+ | `<Button asChild><a /></Button>` | `<Button href>` | Renders React Aria's Link, so the states still work. Needs `RouterProvider` for client-side routes. |
273
350
 
274
351
  The first four are mechanical. `asChild` is not — each site needs a decision
275
352
  about whether it wanted a link or a button.
@@ -287,6 +364,34 @@ Two things that changed on purpose:
287
364
  `[data-theme="dark"]`. Every `dark:` class in the original is gone, not
288
365
  reimplemented.
289
366
 
367
+ ## ButtonGroup
368
+
369
+ ```tsx
370
+ <ButtonGroup
371
+ label="Document actions"
372
+ items={[
373
+ { id: "edit", label: "Edit", icon: <PencilIcon aria-hidden /> },
374
+ { id: "share", label: "Share", reserve: ["Copied"], onSelect: copyLink },
375
+ ]}
376
+ menu={{ label: "More actions", items: [{ id: "del", label: "Delete", destructive: true }] }}
377
+ />
378
+ ```
379
+
380
+ Related actions joined into one surface: a shared border and radius, hairline
381
+ dividers, no gaps. One soft highlight glides between segments under the pointer or
382
+ keyboard focus (it appears in place, and only moves between segments travel), the
383
+ pressed segment answers inside itself, and an optional chevron segment opens more
384
+ actions in a menu that grows from the group's edge.
385
+
386
+ A segment's label can change in place without resizing it: `reserve` lists the
387
+ other labels it can show and the segment sizes to the widest. A label that
388
+ changes right after its segment is pressed (`Share` to `Copied`) is announced
389
+ once. When the row does not fit, segments with an icon drop their label and keep
390
+ the icon (`collapseLabels`). Tab visits every segment; the arrow keys along the
391
+ orientation, Home and End move between them. `variant` is `outline` or
392
+ `solid`, `size` is `sm` or `md`, `orientation` is `horizontal` or
393
+ `vertical`.
394
+
290
395
  ## Calendar
291
396
 
292
397
  **Maturity: `experimental`.** The API may still move; `DateRangePicker` is its
@@ -328,6 +433,18 @@ so a September-focused window opens on August. Range pickers usually want
328
433
  `"start"`. It is inherited from React Aria rather than defaulted here, because
329
434
  either alignment is legitimate.
330
435
 
436
+ ### Motion
437
+
438
+ Built in plain CSS and a little plain React, with no animation library.
439
+
440
+ - A one-month calendar shows its month on a strip with the neighbours beside it. Moving by a month slides the strip on a spring; the other months are faded back and `inert`. A jump of more than a month (Shift + Page Down) brings the new month in from one month away instead of scrolling through the ones between. The window follows the height of the month in view.
441
+ - The month title, now in the header beside the buttons, crosses in place: the new month arrives from the side the strip travels to while the old one leaves toward the other. React Aria's own heading stays, visually hidden, as the text.
442
+ - In a single-date `Calendar`, the selected day's fill is one disc that glides on a straight line between days, grows in when a selection appears in a month and fades out when it leaves. Each number flips colour as the disc passes it: the day it lands on waits longer on long hops, the day it leaves waits longer on short ones.
443
+ - Day and header buttons fill on hover, press to .94 and .96 and spring back. Nothing animates on first render. `CalendarRange` and calendars with several months keep their layout and get the press and hover motion; the range band has no motion yet.
444
+ - Reduced motion swaps the slides and glides for plain fades or nothing.
445
+
446
+ **`DatePicker`** rolls its value part by part (a later date rises in, an earlier one drops in, a part that did not change holds still, and parts after a wider or narrower one slide along). The chevron turns while the list is open, the list grows from the field and leaves quicker, and the trigger fills muted while it is open. A pick is seen for 300ms (the disc glides, the footer confirms it) before the list closes; Clear closes at once. The footer gains a status line (`selectedStatusLabel`, `emptyStatusLabel`) and takes Clear (`clearable`, `clearLabel`) out of the calendar. Reduced motion closes at once and fades.
447
+
331
448
  ### Loading the tokens is not optional
332
449
 
333
450
  `@seekalfred/primitives/styles.css` defines **no** token values — not
@@ -346,6 +463,54 @@ not — and the two that hurt are `--focus-ring-width` and `--focus-ring-offset`
346
463
  because their absence removes the focus indicator rather than merely shifting a
347
464
  colour. `apps/smoke` is the worked example of a correct consumer.
348
465
 
466
+
467
+ ## DateRangePicker
468
+
469
+ ```tsx
470
+ <DateRangePicker label="Invoice dates" value={range} onChange={setRange} />
471
+ ```
472
+
473
+ A trigger that **grows into the panel it opens**: one surface whose width, height,
474
+ corners and offset ride springs from the trigger's box to the panel's and back,
475
+ while the panel's content arrives just behind it and leaves just ahead of it. The
476
+ panel holds a rail of presets (`presets`, with `defaultDateRangePresets` from today
477
+ back to the start of the year), the typed start and end, and two months side by side
478
+ (one month under a scrolling row of presets when there is no room; `months` forces
479
+ either). **Nothing is applied until Apply**: the panel edits a draft, `onChange`
480
+ fires on Apply, and Cancel or Escape throws the draft away. Values are `DateValue`
481
+ objects, either end may be `null`, and `messages` renames every string.
482
+
483
+ **Motion** (plain CSS and a little plain React, no animation library; the five
484
+ springs are sampled from the reference solver):
485
+
486
+ - The surface springs open on the grow spring and shrinks on a slower one; the
487
+ trigger's icons and its value step aside as it grows (120ms, a little blur) and
488
+ come back after it has shrunk; the root stays on top for half a second after
489
+ closing so the shrinking box never slips under a neighbour.
490
+ - The formatted range **flies** from the trigger into the footer as the surface
491
+ grows and back into the trigger as it shrinks; its words roll one by one (a later
492
+ date rises, an earlier one drops, a word that did not change holds still).
493
+ - Months slide together in the direction you travel: the new pair arrives from the
494
+ side you travel to with a little blur while the old one is kept where it stood and
495
+ slides away, `inert`.
496
+ - The highlight is one bar per week under the days, which stretches from the edge the
497
+ range arrives from as you hover or arrow to the end day, plus a solid end at each
498
+ side which glides from day to day. A month that has just arrived shows its
499
+ highlight in place; only a change after that animates.
500
+ - One highlight glides between the presets, and the compact row scrolls to keep the
501
+ active one in view. Days press to .9, buttons to .97 and the header arrows to .96.
502
+
503
+ The calendar is React Aria's `RangeCalendar`, so keyboard, locale and calendar-system
504
+ behaviour are its own; the bars and ends are measured from the cells it renders.
505
+ `clearable` adds a Clear button in the footer. Reduced motion jumps the surface and
506
+ swaps the slides and rolls for plain fades.
507
+
508
+ **Breaking from the earlier DateRangePicker:** the panel now edits a draft that
509
+ Apply commits (it used to commit when the second day was pressed), the calendar and
510
+ the typed fields sit beside a preset rail, and `DateRangePicker` is its own module
511
+ (still exported from `/date-picker`). It reads the viewer's local today for the
512
+ presets, so `@internationalized/date` (a peer dependency) is imported at runtime.
513
+
349
514
  ## Card
350
515
 
351
516
  ```tsx
@@ -385,6 +550,10 @@ so `onClick` works exactly as it did — and a `div` with a click handler i
385
550
  focusable and has no role. One legacy call site does this. Use a `Button` or a
386
551
  link inside the card instead.
387
552
 
553
+ ### Motion (Card)
554
+
555
+ `interactive` makes a card respond to a mouse or pen: it floats 2px on the snappy spring while pointed at, its border darkens (160ms) and its shadow deepens (240ms). Touch never lifts, reduced motion does not lift, and a card without `interactive` never moves. Not ported: a media zoom, a status line and the quick-look morph, which need slots Alfred's card does not have.
556
+
388
557
  ## Checkbox
389
558
 
390
559
  ```tsx
@@ -413,6 +582,45 @@ other form controls will take.
413
582
  | *(no way to set it)* | `isIndeterminate` | `indeterminate` is a DOM property, not an attribute |
414
583
  | *(no label at all)* | `children` | the legacy control had no accessible name |
415
584
 
585
+ ### Motion (Checkbox)
586
+
587
+ The chosen colour grows in from small on the snappy spring while it fades in, the mark draws itself, and the check morphs into the dash and back on the morph spring (the `d` property transitions in Chromium; elsewhere it swaps). The square (not the hit area) presses to .95 in 100ms and springs back; its border eases over 160ms and darkens on hover while unchosen. **DOM:** the two glyph svgs are now one `checkbox-mark` (with `data-mark` of `check` or `indeterminate`) and a `checkbox-fill` element carries the colour, so the box's own background no longer turns primary.
588
+
589
+ ## ConfirmMorph
590
+
591
+ ```tsx
592
+ <ConfirmMorph label="Delete project" onConfirm={deleteProject} onUndo={restoreProject} />
593
+ <ConfirmMorph label="Delete project" confirmMode="hold" onConfirm={deleteProject} />
594
+ ```
595
+
596
+ A button for destructive or important actions that asks in place. The same pill
597
+ morphs into a question with Cancel and Confirm, then a spinner, then a result with
598
+ Undo; its width springs to each face while the faces crossfade at their natural
599
+ width. Use it where a dialog would be heavy.
600
+
601
+ `confirmMode` is `press`, `hold` (a press held for `holdDuration` fills the
602
+ button, and drains back if let go early) or `countdown` (a draining ring and
603
+ ticking seconds that Cancel or Escape can still stop). Escape, a press outside,
604
+ or `confirmTimeout` answer no; resting the pointer on it, or a hidden tab,
605
+ pauses the clock. A press right after the question appears is the tail of the
606
+ opening press, not an answer. A rejection shows an error face with Retry.
607
+ Controlled with `state` and `onStateChange`. A live region announces each step,
608
+ and focus follows the control between faces only when it was already inside.
609
+
610
+ ## CopyButton
611
+
612
+ ```tsx
613
+ <CopyButton value={token} />
614
+ <CopyButton value={url} iconOnly variant="plain" />
615
+ ```
616
+
617
+ Copies a value and says so, calmly: the icon trades places in one soft breath
618
+ (copy, then a tick that draws itself, or an alert if the clipboard refused), the
619
+ label's changed letters rise in, and the button never changes width because its
620
+ label cell reserves the widest of `Copy`, `Copied` and `Failed`. It returns to
621
+ rest by itself, a live region announces the result, and the accessible name is
622
+ always the resting label.
623
+
416
624
  ## Dialog
417
625
 
418
626
  ```tsx
@@ -458,6 +666,128 @@ Two legacy behaviours are deliberately not carried: the enter/exit animation
458
666
  and `data-exiting` for whoever adds it), and the footer's `flex-col-reverse`,
459
667
  which put DOM order and visual order in disagreement below `sm` — WCAG 1.3.2.
460
668
 
669
+ ### Motion (Dialog)
670
+
671
+ The overlay fades in over 240ms and out over 160ms and blurs the page a little; the panel rises 8px and grows from .96 on the smooth spring while it fades in, and leaves over 160ms. A string `title` or `description` swaps as a whole when its text changes. The close button fills on hover and presses to .96. While the dialog leaves, the overlay lets clicks through. New `entrance` prop (`"rise"` or `"none"`) lets an edge-anchored preset opt out; Sheet does. Reduced motion is a plain fade.
672
+
673
+ ## EmptyState
674
+
675
+ ```tsx
676
+ <EmptyState action={<Button size="sm">Clear filters</Button>}>
677
+ No invoices match these filters.
678
+ </EmptyState>
679
+ ```
680
+
681
+ What a list shows when it has nothing to show: one muted line, centred in a
682
+ bordered box the width of the list. `action` is an optional next step under the
683
+ text and is omitted from the DOM when absent.
684
+
685
+ ### Motion (EmptyState)
686
+
687
+ New optional `icon` settles in once when the state first appears (small and transparent to rest over 480ms); a different icon component later swaps in place. A string message swaps as a whole when it changes and the message column follows its height on the smooth spring. The message now sits in an `empty-state-copy` frame.
688
+
689
+ ## ExpandingButtonGroup
690
+
691
+ ```tsx
692
+ <ExpandingButtonGroup
693
+ label="Message actions"
694
+ items={[
695
+ { id: "reply", label: "Reply", icon: <ReplyIcon aria-hidden /> },
696
+ { id: "archive", label: "Archive", doneLabel: "Archived", icon: <ArchiveIcon aria-hidden /> },
697
+ { id: "delete", label: "Delete", tone: "danger", icon: <TrashIcon aria-hidden /> },
698
+ ]}
699
+ />
700
+ ```
701
+
702
+ A compact pill of icon buttons. One action always shows its label; the one you
703
+ point at or focus takes over the label while its neighbours slide aside, on a
704
+ spring. Moving between actions needs the pointer a few pixels inside the next one,
705
+ so resting on a boundary never flips between them. An action can confirm in place
706
+ (`doneLabel`): the icon draws a check and the label becomes a past tense word
707
+ without the width changing. A promise from `onSelect` keeps the button busy.
708
+
709
+ On touch, the first tap reveals the label and a second tap runs the action. A
710
+ disabled action stays focusable and still reveals its label. Arrow keys, Home and
711
+ End rove focus.
712
+
713
+ ## FileDropZone
714
+
715
+ ```tsx
716
+ <FileDropZone
717
+ label="Contract"
718
+ prompt="Drop a contract here"
719
+ hint="PDF only, up to 20 MB."
720
+ acceptedFileTypes={["application/pdf"]}
721
+ onFiles={(files) => upload(files[0])}
722
+ />
723
+ ```
724
+
725
+ A dashed drop area with a "Choose file" button, on React Aria's `DropZone` and
726
+ `FileTrigger`. The browser's own file control ("Choose Files / No file chosen")
727
+ is never shown: the real input is visually hidden and the button opens it, so
728
+ Tab reaches the button and Enter or Space opens the dialog. `hint` is where the
729
+ accepted types and the size limit go, stated before a file is chosen.
730
+ `acceptedFileTypes` and `allowsMultiple` reach the dialog; a drop is not
731
+ filtered, so the consumer decides what it can read. Without `allowsMultiple`
732
+ only the first file is handed over.
733
+
734
+ ### Motion (FileDropZone)
735
+
736
+ The surface eases its fill over 240ms and its edge over 160ms. With a file over it, the zone breathes (swells 1.2% and settles over 2.2s, repeating) and light follows the file: a wash inside and a brighter stretch of the edge nearest it, which lean toward the pointer on a short ease (React Aria's `onDropMove` gives the position). New optional `dropPrompt` swaps in for the prompt while a file is over. Reduced motion does not breathe. Not ported: the upload list, progress bars, retry, thumbnails, paste, the compact prompt, the sheets illustration and the dashes closing into a solid edge.
737
+
738
+ ## FilterBar
739
+
740
+ ```tsx
741
+ <FilterBar filters={filters} inline={<Input aria-label="Search" />} />
742
+ ```
743
+
744
+ A list toolbar: every dropdown filter sits behind one Filters button with a count badge, and each set filter shows as a removable chip with Clear all.
745
+
746
+ ### Motion
747
+
748
+ A chip added after the first render opens its slot from nothing while it fades up from a blur and scales in, so the chips beside it glide over; a removed chip stays while it fades and shrinks over 120ms and its slot collapses on a spring; a changed value swaps its text and the chip's width springs to it; Clear all comes and goes in its own slot; and the row follows its height when a chip makes a line wrap. Nothing animates at first render, and a removed chip is `aria-hidden` and `inert` while it leaves. The slot widths animate from and to `auto`, which needs `interpolate-size` (Chromium); elsewhere they snap, still correct. Not ported: the Add-filter button that grows into a field-and-value menu (Alfred's Filters button opens a popover of selects, which gets the Popover motion below).
749
+
750
+ ## FloatingButtonGroup
751
+
752
+ ```tsx
753
+ <FloatingButtonGroup
754
+ label="Board actions"
755
+ items={[
756
+ { id: "undo", label: "Undo", icon: <UndoIcon aria-hidden />, iconOnly: true, shortcut: "⌘Z" },
757
+ { type: "separator" },
758
+ { id: "present", label: "Present", isPressed: presenting, onSelect: toggle },
759
+ ]}
760
+ />
761
+ ```
762
+
763
+ Separate soft buttons in a quiet tray (a `role="toolbar"`). One shared highlight
764
+ morphs, in position and size, to whichever button the pointer or keyboard focus is
765
+ on, fades when the pointer leaves, and settles darker and a touch smaller while a
766
+ button is pressed. Arrow keys move focus (skipping disabled buttons), Home and End
767
+ jump to the ends, and only one button is a tab stop.
768
+
769
+ `reserveLabels` lists the other labels an item can show, so a label change
770
+ (`Share` to `Copied`) never moves anything. Icon-only items put their label,
771
+ and any `shortcut` hint, in a tooltip. `isPressed` marks a mode toggle.
772
+ `variant` is `muted` or `floating` (raised, for an overlay).
773
+
774
+ ## IconMorph
775
+
776
+ ```tsx
777
+ <Button aria-label="Menu" aria-pressed={open} onPress={toggle}>
778
+ <IconMorph name="menu" state={open} />
779
+ </Button>
780
+ ```
781
+
782
+ Icons that morph between two states instead of swapping: `menu` to close, `play`
783
+ to pause, `plus` to check, `copy` to check, `theme` (sun to moon), `chevron`
784
+ (turned), `bell` (notifications on) and `heart` (liked). Lines rotate and slide,
785
+ shapes interpolate their outlines, and two carry a moment of their own: the bell
786
+ rings when it turns on, and the heart bursts when liked. Every morph rides an
787
+ interruptible spring, so toggling quickly reverses from wherever the icon is. The
788
+ icons are decorative: put them in a button that carries the name and
789
+ `aria-pressed`. Each is also exported on its own (`MenuIcon`, `HeartIcon`, …).
790
+
461
791
  ## Input and Textarea
462
792
 
463
793
  ```tsx
@@ -483,6 +813,7 @@ invalid styling on only one of them.
483
813
  | `value` / `onChange` | unchanged | still a real DOM event |
484
814
  | `disabled` | unchanged | native |
485
815
  | `aria-invalid` | unchanged | drives the invalid border with no prop of our own |
816
+ | hand-rolled `<label>` + helper + error text | `label` / `description` / `error` | optional; see below |
486
817
  | `h-9` (36px) | density scale | **46px at the default**, matching Button |
487
818
  | `text-base md:text-sm` | `text-base` | one size; 16px avoids iOS Safari zooming on focus |
488
819
  | `min-h-[80px]` | `min-h-20` | same 80px, from the spacing scale |
@@ -537,7 +868,90 @@ second name source makes it announce twice.
537
868
 
538
869
  **An invalid field needs a message.** The border changes colour, and colour
539
870
  alone is not a state cue (WCAG 1.4.1). Pair `aria-invalid` with
540
- `aria-describedby` pointing at the error text.
871
+ `aria-describedby` pointing at the error text, or use the `error` prop below,
872
+ which does both for you.
873
+
874
+ ### Label, description and error
875
+
876
+ ```tsx
877
+ <Input
878
+ label="Email"
879
+ description="We only use it to send receipts."
880
+ error={touched && !valid ? "Enter a valid email address." : undefined}
881
+ value={v}
882
+ onChange={(e) => set(e.target.value)}
883
+ />
884
+ ```
885
+
886
+ `Input` and `Textarea` take three optional props. `label` is a `ReactNode`
887
+ shown above the control. `description` (helper text) and `error` are plain
888
+ **strings**, because the library animates their copy word by word. An empty or
889
+ `undefined` `error` closes the error row.
890
+
891
+ **Passing any of the three wraps the control in a field wrapper, even when the
892
+ value is `undefined`. Passing none leaves the DOM exactly what it was before
893
+ these props existed:** the bare control, with no generated `id`. The wrapper is
894
+ decided by whether the prop is present, not by its value. That is deliberate:
895
+ `error={maybeMessage}` is the common shape, and if the wrapper came and went
896
+ with the message, the input would move in the tree every time validation
897
+ changed. React would remount it, losing focus and any uncontrolled value.
898
+ Toggling `error` never remounts the input.
899
+
900
+ The wiring, done for you:
901
+
902
+ - `label` is tied to the control with `htmlFor`. The control keeps your `id`, or
903
+ gets a generated one.
904
+ - `description` and `error` are named in `aria-describedby`, after any value
905
+ you passed yourself.
906
+ - A non-empty `error` sets `aria-invalid`, so the field takes its invalid border
907
+ with no extra prop. Without `error`, your own `aria-invalid` still applies.
908
+ - The error row has `role="alert"`.
909
+ - Screen readers get the plain copy. The animated words are `aria-hidden`.
910
+
911
+ Limits worth knowing:
912
+
913
+ - **Do not pass `aria-label` together with `label`.** `aria-label` wins, so the
914
+ visible name is no longer the accessible one (WCAG 2.5.3).
915
+ - **Inside a React Aria `TextField` the three props are ignored.** The
916
+ `TextField` supplies its own label and messages.
917
+ - The alert node is inserted together with its text. Whether a given screen
918
+ reader announces it on insertion is not tested here, so check your own
919
+ combinations if announcement timing matters.
920
+
921
+ ### Motion
922
+
923
+ The rows move, in pure CSS plus a little plain React (no animation library):
924
+
925
+ - **A row opens on a spring** when `description` or `error` appears after the
926
+ field has mounted, and **closes the same way** when it goes away, still
927
+ showing its last text while it shuts. A row present on first render is just
928
+ there: nothing animates on mount.
929
+ - **Changed words rise in** from a soft blur, and the old ones lift away from
930
+ where they stood. Words are matched by position, so changing a word near the
931
+ front replaces every word after it.
932
+ - **Numbers roll digit by digit.** In copy such as "12 characters", only the
933
+ digits that changed move. They arrive from below when the number grew and from
934
+ above when it shrank. A gained digit opens its width and a lost one closes it.
935
+ - **The row follows the copy's height**, so text that wraps opens its next line
936
+ instead of being clipped.
937
+ - **Reduced motion swaps instantly.** There is no movement and no collapsed
938
+ frame.
939
+
940
+ The `Textarea` lifts replaced words away slightly faster than `Input` (160ms
941
+ against 180ms).
942
+
943
+ ### The look of a field
944
+
945
+ These changes are shared by `Input`, `Textarea`, `Select` and `DatePicker`,
946
+ because they all use `fieldSurfaceVariants`:
947
+
948
+ - The border and background transition in 160ms on the settle curve. Only those
949
+ two properties animate; the focus outline switches without a transition.
950
+ - **Hover and keyboard focus darken the border** to the text colour. The focus
951
+ ring stays, because forced-colors drops border colours and the ring is the
952
+ visible focus indicator.
953
+ - A disabled field takes the muted background and does not darken on hover.
954
+ - An invalid field always keeps its invalid border, hovered or focused.
541
955
 
542
956
  ## Label
543
957
 
@@ -589,6 +1003,91 @@ the dropped nine turns out to be wanted.
589
1003
  from `useLocale()`, so `dir` on the document does **not** reach a portalled
590
1004
  menu.
591
1005
 
1006
+ ## PageHeader
1007
+
1008
+ ```tsx
1009
+ <PageHeader
1010
+ title="Projects"
1011
+ description="Everything your team is working on."
1012
+ meta="updated 14 Oct 2026"
1013
+ actions={<Button>New project</Button>}
1014
+ />
1015
+ ```
1016
+
1017
+ The page's single `<h1>`, in the display face at the `h3` step of the scale.
1018
+ `description` sits under it, muted and capped at a readable measure. `actions`
1019
+ share the title's row at its end and wrap below it on a narrow screen. `meta`
1020
+ sits inline after the title on its baseline. `back` is a slot above the title
1021
+ for a detail page's way back: pass your router's link, the header only sizes
1022
+ and colours it. Every slot is omitted from the DOM when its prop is absent.
1023
+
1024
+ ## PasswordField
1025
+
1026
+ ```tsx
1027
+ <PasswordField label="Password" description="At least eight characters." />
1028
+ <PasswordField
1029
+ label="Contraseña"
1030
+ messages={{ showPassword: "Mostrar contraseña", hidePassword: "Ocultar contraseña" }}
1031
+ />
1032
+ ```
1033
+
1034
+ A password input with a show and hide toggle inside the field's border. Switching
1035
+ resolves the text in place (the dots or characters settle in from a soft blur, on
1036
+ every toggle but never on mount), and the eye is one outline that a slash draws
1037
+ across and undraws, cutting the outline beneath it, instead of swapping two
1038
+ icons. The toggle presses to 0.96 and springs back; the helper text opens and
1039
+ changes the way `Input`'s does (words only, so a count in it does not roll).
1040
+
1041
+ `label` is required and visible. `description` is optional helper text.
1042
+ `messages` renames the toggle (`showPassword`, `hidePassword`, English by
1043
+ default). Native input props apply to the input, and `className` goes to the
1044
+ input; `disabled` also disables the toggle, and `aria-invalid` gives the box its
1045
+ invalid border. The box shares the field look of `Input` and `Textarea`.
1046
+
1047
+ **The toggle's name changes with its state and it is also `aria-pressed`**, so a
1048
+ screen reader may announce both ("Hide password, pressed"); this is not tested
1049
+ with a screen reader. Reduced motion drops every transition and the press.
1050
+
1051
+ ## PasswordStrength
1052
+
1053
+ ```tsx
1054
+ <PasswordStrength label="New password" onValueChange={(value, strength) => setLevel(strength.level)} />
1055
+ <PasswordStrength label="Account password" rules={createPasswordRules({ minLength: 10 })} error={error} />
1056
+ ```
1057
+
1058
+ A new-password field that shows how strong the password is while it is typed.
1059
+ Use it when creating or changing a password; use `PasswordField` for sign in.
1060
+ Scoring happens on the device and nothing is sent anywhere.
1061
+
1062
+ It is the `PasswordField` box and eye toggle plus three things: a four-segment
1063
+ meter, the strength word beside it, and a checklist. The segments slide in on a
1064
+ spring and change tone with the level (`destructive-strong`, `warning-strong`,
1065
+ `success-strong`); a pasted password fills left to right in a short wave and
1066
+ clearing empties right to left. The word swaps in place, rising when strength
1067
+ improves and dropping when it falls. Each rule checks off with a tick that draws
1068
+ itself and a tint that grows under it (rules that change together check off in a
1069
+ wave), and the length rule shows a rolling "N more" count while it is unmet. A
1070
+ new `error` opens its row on a spring, swaps as a whole when it changes, and
1071
+ shakes the field once.
1072
+
1073
+ Strength is the share of rules met, spread over four steps, held at the first
1074
+ step while the password is shorter than `shortLength` (8). `rules` takes your own
1075
+ list, or `createPasswordRules({ minLength, labels })` for the built-in four
1076
+ (length, mixed case, number, symbol); `estimateStrength` scores a password
1077
+ without the component. `value` and `defaultValue` work as on an input, and
1078
+ `onValueChange` receives the new value and its strength. `revealed` and
1079
+ `onRevealedChange` control the toggle. `messages` renames every string (the
1080
+ strength words, the toggle, the meter, the list, the "N more" words and the
1081
+ summary) and merges with the English defaults.
1082
+
1083
+ Native input props apply to the input, and `className` goes to the input;
1084
+ `disabled` also disables the toggle, and `aria-invalid` gives the box its invalid
1085
+ border. The error is an alert and is named in `aria-describedby` together with the
1086
+ requirements list. The toggle is a pressed button whose name does not change. A
1087
+ polite live region reads a summary when the level or the count of met rules
1088
+ changes. Reduced motion drops the transitions, the shake and the press, and the
1089
+ swapped text fades instead of moving. Screen-reader behaviour is not tested.
1090
+
592
1091
  ## Popover
593
1092
 
594
1093
  ```tsx
@@ -608,6 +1107,10 @@ dialog. React Aria labels it from the trigger.
608
1107
  | `<PopoverContent align side>` | `<Popover placement>` | `align="start"` → `"bottom start"` |
609
1108
  | `<PopoverAnchor>` | *no equivalent* | zero call sites |
610
1109
 
1110
+ ### Motion (Popover)
1111
+
1112
+ The panel grows out of its trigger: it starts 5px toward it (the side it opened on) and a little small, and settles on the gentle spring while it fades in over 160ms; it leaves in 160ms, half the offset and a hair smaller, with clicks passing through. A close that interrupts an open reverses from where the panel is. New `entrance` prop (`"rise"` default, `"none"`) lets a picker that animates its own popover opt out; DatePicker does. Reduced motion is a plain fade.
1113
+
611
1114
  ## Progress
612
1115
 
613
1116
  ```tsx
@@ -632,6 +1135,10 @@ and no number. `formatOptions` reaches it too, so a currency or "3 of 7" needs n
632
1135
  second prop. `trackClassName` and `fillClassName` are escape hatches for a colour
633
1136
  no tone covers.
634
1137
 
1138
+ ### Motion (Progress)
1139
+
1140
+ The fill follows the value on the smooth spring (it was a 200ms ease) and the printed percentage counts to the new number on the same spring, so the number always matches the bar. When a default-toned bar completes it eases to the success colour (after 200ms) and a check lands beside the count; an explicit `tone` is never overridden, and a bar that is full at mount shows its check with no motion. A value that is not a percentage (a price) is shown as it is. A changed string label swaps as a whole.
1141
+
635
1142
  ## RadioGroup
636
1143
 
637
1144
  ```tsx
@@ -651,6 +1158,10 @@ no tone covers.
651
1158
  **The label moves inside the item.** That is the one rename a call site
652
1159
  makes, and it removes the id bookkeeping Radix required.
653
1160
 
1161
+ ### Motion (RadioGroup)
1162
+
1163
+ The chosen dot springs in from small on the snappy spring (opacity 160ms in, 120ms out) while the old one shrinks away, and the mark's colours ease over 160ms. A press dips the mark to .88 in 100ms and it springs back on the gentle spring. **DOM:** the `radio-indicator` is always rendered (with `data-selected`), not only while chosen. The reference's travelling highlight between bordered rows is not ported: Alfred's radios are not row cards.
1164
+
654
1165
  ## ScrollArea
655
1166
 
656
1167
  ```tsx
@@ -673,6 +1184,33 @@ name — the three go together.
673
1184
  pixel width, radius, hover state or margins. Those need `::-webkit-scrollbar`,
674
1185
  which Firefox has never supported.
675
1186
 
1187
+ ### Motion (ScrollArea)
1188
+
1189
+ New opt-in `fade` (px, default `0` = off, so an area is unchanged) fades the content out at an edge by exactly as much as there is content hidden past it, up to that many pixels: the fade grows in with the first pixels of scroll and is gone at the ends. It is written on each scroll frame straight to the element, so scrolling never re-renders. Not ported: the overlay scrollbars that fade in and out and thicken on hover (they replace the native scrollbar with a script-drawn one).
1190
+
1191
+ ## SearchField
1192
+
1193
+ ```tsx
1194
+ <SearchField label="Search contacts" value={query} onValueChange={setQuery} />
1195
+ ```
1196
+
1197
+ A search input with a clear button. It is controlled: `value` and
1198
+ `onValueChange(text)`, which is called with every edit and with `""` when the
1199
+ clear button is pressed (so there is no `onChange`). The clear button has a
1200
+ reserved box, so the field never changes width as it comes and goes; it scales
1201
+ in on the snappy spring while it fades and unblurs, leaves a little smaller,
1202
+ presses to 0.96, and pressing it clears the text and returns focus to the
1203
+ input. A field that is filled on first render shows the button with no
1204
+ animation, and while the button leaves it cannot be pressed or focused. The
1205
+ magnifier is muted at rest and takes full contrast while the field has focus or
1206
+ a value.
1207
+
1208
+ `label` is required and visible; `clearLabel` renames the button (default "Clear
1209
+ search"). Native input props apply to the input, `className` goes to the input,
1210
+ and `disabled` also disables the clear button. The browser's own clear button
1211
+ and search decoration are hidden. The box shares the field look of `Input`.
1212
+ Reduced motion fades the button in over 120ms and removes it at once.
1213
+
676
1214
  ## Select
677
1215
 
678
1216
  ```tsx
@@ -686,7 +1224,20 @@ which Firefox has never supported.
686
1224
  Four legacy parts collapse into one. The trigger shares
687
1225
  `fieldSurfaceVariants` with `Input`, so a select and a text field in the
688
1226
  same form row are the same box — the legacy had `h-10` here against
689
- `h-9` on the input.
1227
+ `h-9` on the input. Its label uses `Label`'s classes, so a Select and a
1228
+ labelled `Input` in one form show one label style. The trigger also shares the
1229
+ field look described under Input and Textarea (border transition, darker border
1230
+ on hover and keyboard focus, muted background when disabled).
1231
+
1232
+ ### Motion
1233
+
1234
+ Built in plain CSS and a little plain React, with no animation library.
1235
+
1236
+ - The shown value rolls when the selection changes: a later option rises in from below and an earlier one drops in from above (the direction is the option's place in the list), while the old value lifts away. A value that is already chosen on first mount does not animate. The visible copy is `aria-hidden`; React Aria's own value, visually hidden beside it, is what a screen reader reads.
1237
+ - The chevron turns half a turn on a spring while the list is open.
1238
+ - The list grows from the trigger edge (offset 6px toward it, scaled to 0.97, 240ms) and leaves faster (120ms), and a selected option's tick pops in. An option's highlight fades in over 120ms.
1239
+ - Hover, press and an open list fill the trigger muted as well as darkening its border. An invalid or disabled trigger is not repainted.
1240
+ - Reduced motion replaces the roll, the list's grow and the tick's pop with plain fades, and the chevron turns without a transition.
690
1241
 
691
1242
  ### Migrating from the legacy Select
692
1243
 
@@ -793,6 +1344,10 @@ replaced.
793
1344
 
794
1345
  The pulse is gated behind `motion-safe:`.
795
1346
 
1347
+ ### Motion (Skeleton)
1348
+
1349
+ The pulse dims a block to about half and back over 1.8s, 11 times (about 20 seconds), then rests, so a stalled load does not pulse forever. The new optional `index` prop starts a block's pulse 90ms after the one above it (`<Skeleton index={2} />`), so a stack dims in a wave. Reduced motion shows the still block. The reference's placeholder-to-content crossfade is not ported: it needs a wrapper with a `loading` prop, which Alfred does not have.
1350
+
796
1351
  ## Slider
797
1352
 
798
1353
  ```tsx
@@ -804,6 +1359,47 @@ The pulse is gated behind `motion-safe:`.
804
1359
  The thumb is 24×24 (WCAG 2.5.8); the legacy's was 16. `aria-valuetext` comes
805
1360
  free, so a scrubber can announce a time instead of a raw second count.
806
1361
 
1362
+ ### Motion (Slider)
1363
+
1364
+ A value that arrives from outside a drag (a key, a prop, a click on the track) moves the thumb and the fill on the snappy spring; while dragging they stay under the pointer. The thumb swells to 1.16 while held. A bubble with the value rises in over the thumb (from small and blurred) while it is dragged or keyboard focused, and drops away in 120ms; the readout and the bubble roll their value up or down as it changes. The bubble is `aria-hidden`: the output beside the label carries the value. Reduced motion keeps opacity changes only. Not ported: marks, range, the springing catch-up on a click of the track (React Aria starts a drag at once), rubber-banding past the limits and release momentum.
1365
+
1366
+ ## SplitButton
1367
+
1368
+ ```tsx
1369
+ <SplitButton
1370
+ onPress={save}
1371
+ actions={[{ label: "Save as draft", onSelect: draft }, { label: "Delete", destructive: true }]}
1372
+ >
1373
+ Save
1374
+ </SplitButton>
1375
+ ```
1376
+
1377
+ One pill, two actions: a main action and a menu of nearby alternatives. Change
1378
+ `children` (or `icon`) and the main half morphs in place: the letters that differ
1379
+ rise in while the old ones lift away, the icon swaps by scaling, and the width
1380
+ springs. The whole pill presses in for the main action; the menu half answers in
1381
+ colour only, so the menu opens from a still anchor, growing from its trigger with
1382
+ its items arriving one after another. `variant` is `primary` or `secondary`.
1383
+
1384
+ ## StatCard
1385
+
1386
+ ```tsx
1387
+ <div className="grid gap-4 sm:grid-cols-3">
1388
+ <StatCard label="Open" value="$12,400" sub="18 invoices" />
1389
+ <StatCard label="Overdue" value="$3,100" sub="4 invoices" tone="negative" />
1390
+ <StatCard label="Unmapped" value="$1,200" isSelected={filter === "unmapped"} onPress={() => setFilter("unmapped")} />
1391
+ </div>
1392
+ ```
1393
+
1394
+ One headline figure. The label is one line (an ellipsis, the full text on
1395
+ hover), so a row's numbers sit level and cards in a row share one height.
1396
+ `tone` is a meaning, not a colour: `neutral` (default, and right for every
1397
+ zero), `positive`, `negative`, `warning`, `attention`. `href` makes the card a
1398
+ link, `onPress` a button, and `isSelected` with `onPress` a filter toggle with
1399
+ `aria-pressed` and one selected border. For a status pill next to the figure,
1400
+ use `Badge`, whose `tone` already covers success, warning, danger, info,
1401
+ primary and neutral.
1402
+
807
1403
  ## Switch
808
1404
 
809
1405
  ```tsx
@@ -811,6 +1407,10 @@ free, so a scrubber can announce a time instead of a raw second count.
811
1407
  <Switch aria-label="Mute" isSelected={muted} onChange={setMuted} />
812
1408
  ```
813
1409
 
1410
+ ### Motion (Switch)
1411
+
1412
+ The thumb travels on a no-overshoot spring (559ms), stretches 5px toward its travel while pressed, and swells once (`scaleX` 1.16, 340ms) when the user changes the state; a switch that mounts on, or whose controlled value changes from outside, does not swell. Reduced motion keeps the colour change only.
1413
+
814
1414
  ### Migrating from the legacy Switch
815
1415
 
816
1416
  | legacy | Alfred | |
@@ -970,6 +1570,10 @@ one consumer or none across the six legacy tables, and they belong to a future
970
1570
  </Tabs>
971
1571
  ```
972
1572
 
1573
+ ### Motion (Tabs)
1574
+
1575
+ One selection highlight (a pseudo-element of the tab list) glides to the selected tab on the morph spring; it is placed without a transition at first. The panel arrives fading in over 240ms while it travels 8px from the side the new tab sits on (it flips in RTL). **DOM:** none added, but once the row has placed its highlight (`data-hl` on the list) the selected tab no longer paints its own fill, border or shadow; the list's `::before` does. Reduced motion jumps the highlight and fades the panel. Not ported: the outgoing panel's exit, the panel height morph, and scroll buttons.
1576
+
973
1577
  ### Migrating from the legacy Tabs
974
1578
 
975
1579
  | legacy | Alfred | |
@@ -991,6 +1595,48 @@ The selected tab carries a **border**, not just a fill: `bg-background` on
991
1595
  a `bg-muted` list is 1.13:1, and WCAG 1.4.11 wants 3:1 for the thing
992
1596
  telling you which tab is active.
993
1597
 
1598
+ ## ThemeSwitch
1599
+
1600
+ ```tsx
1601
+ <ThemeSwitch theme={theme} variant="eclipse" onThemeChange={(next, variant, trigger) => setTheme(next)} />
1602
+ ```
1603
+
1604
+ A light/dark switch whose sun and moon turn the same way (clockwise into dark and
1605
+ back out of it), so the swap reads as one rotation. The icon is nudged the way the
1606
+ page transition travels (`variant`: `reveal`, `eclipse`, `split`, `rise`) and
1607
+ eases back when the theme returns. A page that loads in dark mode never spins its
1608
+ switches: only changes after the first painted frames animate.
1609
+
1610
+ It is controlled: it shows `theme` and calls `onThemeChange` with the theme to
1611
+ move to, the variant and the button (the origin a page transition can grow from).
1612
+ The page transition itself, and the theme, are the caller's.
1613
+
1614
+ ## TimePicker
1615
+
1616
+ ```tsx
1617
+ import { Time } from "@internationalized/date";
1618
+
1619
+ <TimePicker label="Start time" value={time} onChange={setTime} minuteStep={30} />
1620
+ ```
1621
+
1622
+ A field that shows a time and opens a list of times to pick from. It is a React
1623
+ Aria `Select` underneath (a listbox with typeahead, arrow keys and a roving
1624
+ highlight), so it reads and behaves as one. **Values are `TimeValue`**, from
1625
+ `@internationalized/date` like `Calendar`'s and `DatePicker`'s dates (ADR 0006),
1626
+ not `"HH:MM"` strings; `onChange` hands back a `Time`. Unlike `Calendar`, this one
1627
+ builds a `Time` at runtime, so the peer dependency is a real import here.
1628
+ `label` is required. `minuteStep` sets the gap between the times in the list
1629
+ (1, 5, 10, 15 or 30; a time between steps is added to the list so it is never lost),
1630
+ and `hourCycle` chooses 12 or 24 hour times.
1631
+
1632
+ **Motion** (plain CSS and a little plain React, no animation library): the shown
1633
+ time rolls like a clock face (a later time rises in from below, an earlier one
1634
+ drops in from above, and the old one lifts away); the chevron turns half a turn on
1635
+ a spring while the list is open; the list grows from the field and leaves quicker;
1636
+ it opens already centred on the chosen time, with no scroll to watch; the
1637
+ highlight follows the arrows in 80ms; the trigger fills muted while open. Nothing
1638
+ animates on first render, and reduced motion swaps the grow for a plain fade.
1639
+
994
1640
  ## Toast
995
1641
 
996
1642
  ```tsx
@@ -1022,6 +1668,10 @@ user must act on.
1022
1668
  `tone` matches Badge's vocabulary. The legacy had `default` and `destructive`
1023
1669
  only, so 38 of 67 calls passed a variant that could not say "this worked".
1024
1670
 
1671
+ ### Motion (Toast)
1672
+
1673
+ A toast rises 16px from below and grows from .96 on the morph spring while it fades in over 240ms. Pressing its close control sinks it out (8px, .97, 180ms) before it leaves the queue; the control dips to .96 under a press and springs back. Reduced motion fades only. React Aria 1.21 unmounts a closed toast at once and has no exit of its own, so a toast that leaves by its timeout or by `toastQueue.close` still leaves without the exit. Not ported: swipe-to-dismiss, the status icon.
1674
+
1025
1675
  ## Tooltip
1026
1676
 
1027
1677
  ```tsx
@@ -1044,3 +1694,8 @@ tooltip in the app. Focus opens it immediately regardless.
1044
1694
  **RTL differs between the two.** Popover stamps its own `dir` from
1045
1695
  `useLocale()`, so it needs an `I18nProvider`. Tooltip sets no `dir` at all and
1046
1696
  inherits from the document. Set both if you support RTL.
1697
+
1698
+ ### Motion (Tooltip)
1699
+
1700
+ A tooltip rises 3px from its trigger and settles from .97 over 160ms (offset follows the side it opens on), and leaves in 120ms. While any tooltip is open, and for 300ms after the last one closes, the next one only fades in (no travel). The open delay stays 0. Reduced motion fades only. **DOM:** an empty hidden `span` inside the tooltip marks the warm case. Not ported: the crossfade and size spring when a string tooltip's text changes while open.
1701
+