@sableui/react 0.13.2 → 0.14.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 (182) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +114 -0
  3. package/CONTRIBUTING.md +6 -4
  4. package/DESIGN.md +8 -3
  5. package/dist/blocks/approval-block.js +93 -0
  6. package/dist/blocks/approval-block.meta.js +23 -0
  7. package/dist/blocks/index.d.ts +16 -1
  8. package/dist/blocks/index.js +4 -0
  9. package/dist/components/accordion/accordion.meta.js +8 -2
  10. package/dist/components/area-chart/area-chart.meta.js +10 -3
  11. package/dist/components/attachment/attachment.meta.js +26 -0
  12. package/dist/components/bar-chart/bar-chart.meta.js +11 -4
  13. package/dist/components/bottom-nav/bottom-nav.js +3 -1
  14. package/dist/components/bottom-nav/bottom-nav.meta.js +32 -0
  15. package/dist/components/breadcrumbs/breadcrumbs.meta.js +26 -0
  16. package/dist/components/button/button.js +21 -4
  17. package/dist/components/button/button.meta.js +7 -7
  18. package/dist/components/calendar/calendar.js +18 -1
  19. package/dist/components/calendar/calendar.meta.js +38 -0
  20. package/dist/components/carousel/carousel.js +6 -4
  21. package/dist/components/carousel/carousel.meta.js +42 -0
  22. package/dist/components/chat-composer/chat-composer.js +21 -21
  23. package/dist/components/chat-composer/chat-composer.meta.js +38 -0
  24. package/dist/components/chat-composer/use-eased-growth.js +70 -0
  25. package/dist/components/checkbox/checkbox-mark.js +8 -1
  26. package/dist/components/checkbox/checkbox.js +3 -1
  27. package/dist/components/checkbox/checkbox.meta.js +32 -0
  28. package/dist/components/checkbox-card/checkbox-card.js +3 -0
  29. package/dist/components/checkbox-card/checkbox-card.meta.js +32 -0
  30. package/dist/components/code-block/code-block.js +14 -3
  31. package/dist/components/code-block/code-block.meta.js +28 -1
  32. package/dist/components/collapsible/collapsible.meta.js +32 -0
  33. package/dist/components/color-field/color-field.meta.js +26 -0
  34. package/dist/components/combobox/combobox.meta.js +44 -0
  35. package/dist/components/command-menu/command-menu.js +8 -3
  36. package/dist/components/command-menu/command-menu.meta.js +38 -0
  37. package/dist/components/confirm-button/confirm-button.js +179 -0
  38. package/dist/components/confirm-button/confirm-button.meta.js +143 -0
  39. package/dist/components/confirm-button/index.js +7 -0
  40. package/dist/components/data-table/data-table.js +6 -2
  41. package/dist/components/data-table/data-table.meta.js +32 -0
  42. package/dist/components/entity/entity.meta.js +26 -0
  43. package/dist/components/feedback/feedback.js +113 -47
  44. package/dist/components/feedback/feedback.meta.js +49 -4
  45. package/dist/components/input-group/input-group.meta.js +27 -1
  46. package/dist/components/line-chart/line-chart.meta.js +10 -3
  47. package/dist/components/middle-truncate/middle-truncate.meta.js +1 -1
  48. package/dist/components/note/note.meta.js +32 -0
  49. package/dist/components/notification-badge/notification-badge.js +17 -5
  50. package/dist/components/notification-badge/notification-badge.meta.js +34 -2
  51. package/dist/components/number-ticker/index.js +7 -0
  52. package/dist/components/number-ticker/number-ticker.js +246 -0
  53. package/dist/components/number-ticker/number-ticker.meta.js +159 -0
  54. package/dist/components/otp-field/otp-field.js +23 -3
  55. package/dist/components/otp-field/otp-field.meta.js +23 -2
  56. package/dist/components/panel-group/panel-group.meta.js +26 -0
  57. package/dist/components/panel-section/panel-section.js +37 -10
  58. package/dist/components/panel-section/panel-section.meta.js +32 -0
  59. package/dist/components/pie-chart/pie-chart.js +20 -11
  60. package/dist/components/pie-chart/pie-chart.meta.js +17 -4
  61. package/dist/components/pill/pill.meta.js +32 -0
  62. package/dist/components/radar-chart/radar-chart.js +4 -3
  63. package/dist/components/radar-chart/radar-chart.meta.js +10 -3
  64. package/dist/components/radial-chart/radial-chart.js +22 -22
  65. package/dist/components/radial-chart/radial-chart.meta.js +17 -4
  66. package/dist/components/radio/radio.meta.js +26 -0
  67. package/dist/components/radio-card/radio-card.meta.js +38 -0
  68. package/dist/components/relative-time/relative-time.meta.js +32 -0
  69. package/dist/components/scroller/scroller.meta.js +32 -0
  70. package/dist/components/scrub-field/scrub-field.meta.js +32 -0
  71. package/dist/components/search-input/search-input.js +9 -7
  72. package/dist/components/search-input/search-input.meta.js +26 -0
  73. package/dist/components/show-more/show-more.js +1 -4
  74. package/dist/components/show-more/show-more.meta.js +38 -0
  75. package/dist/components/side-nav/side-nav.js +21 -1
  76. package/dist/components/side-nav/side-nav.meta.js +32 -0
  77. package/dist/components/side-nav/side-nav.variants.js +3 -1
  78. package/dist/components/sidebar/sidebar.js +31 -8
  79. package/dist/components/sidebar/sidebar.meta.js +38 -0
  80. package/dist/components/slider/slider.meta.js +26 -0
  81. package/dist/components/sparkline/sparkline.js +7 -4
  82. package/dist/components/sparkline/sparkline.meta.js +9 -2
  83. package/dist/components/table/table.meta.js +26 -0
  84. package/dist/components/tabs/tabs.js +27 -8
  85. package/dist/components/tabs/tabs.meta.js +46 -3
  86. package/dist/components/theme-selector/theme-selector.meta.js +32 -0
  87. package/dist/components/theme-switcher/theme-switcher.js +4 -1
  88. package/dist/components/theme-switcher/theme-switcher.meta.js +42 -0
  89. package/dist/components/toggle/toggle.js +28 -5
  90. package/dist/components/toggle/toggle.meta.js +44 -2
  91. package/dist/components/tool-call/index.js +8 -0
  92. package/dist/components/tool-call/tool-call.js +117 -0
  93. package/dist/components/tool-call/tool-call.meta.js +147 -0
  94. package/dist/components/tool-call/tool-call.variants.js +33 -0
  95. package/dist/components/tooltip/index.js +3 -1
  96. package/dist/components/tooltip/tooltip.js +84 -0
  97. package/dist/components/tooltip/tooltip.meta.js +16 -1
  98. package/dist/components/tree/tree.js +71 -6
  99. package/dist/components/tree/tree.meta.js +32 -0
  100. package/dist/components/video/video.meta.js +32 -0
  101. package/dist/file-hashes.json +114 -97
  102. package/dist/foundations/motion.js +49 -3
  103. package/dist/index.d.ts +614 -44
  104. package/dist/index.js +4 -1
  105. package/dist/lib/chart/cartesian.js +9 -10
  106. package/dist/lib/chart/chart-center.js +30 -0
  107. package/dist/lib/chart/chart-frame.js +15 -8
  108. package/dist/lib/chart/dim.js +13 -0
  109. package/dist/lib/chart/format.js +7 -1
  110. package/dist/lib/chart/motion.js +6 -4
  111. package/dist/lib/highlight-glide.js +68 -10
  112. package/dist/lib/taxonomy.js +3 -0
  113. package/dist/motion/MotionProvider.js +45 -3
  114. package/dist/motion/Reveal.js +1 -1
  115. package/dist/motion/TextReveal.js +1 -1
  116. package/dist/motion/index.js +15 -2
  117. package/dist/motion/useAutoSize.js +42 -0
  118. package/dist/motion/useShake.js +25 -0
  119. package/dist/styles.css +298 -5
  120. package/llms/blocks/approval-block.md +128 -0
  121. package/llms/components/accordion.md +3 -2
  122. package/llms/components/area-chart.md +4 -3
  123. package/llms/components/attachment.md +7 -0
  124. package/llms/components/bar-chart.md +5 -4
  125. package/llms/components/bottom-nav.md +8 -0
  126. package/llms/components/breadcrumbs.md +7 -0
  127. package/llms/components/button.md +6 -6
  128. package/llms/components/calendar.md +9 -0
  129. package/llms/components/carousel.md +17 -0
  130. package/llms/components/chat-composer.md +9 -0
  131. package/llms/components/checkbox-card.md +8 -0
  132. package/llms/components/checkbox.md +8 -0
  133. package/llms/components/code-block.md +8 -0
  134. package/llms/components/collapsible.md +8 -0
  135. package/llms/components/color-field.md +7 -0
  136. package/llms/components/combobox.md +10 -0
  137. package/llms/components/command-menu.md +9 -0
  138. package/llms/components/confirm-button.md +92 -0
  139. package/llms/components/data-table.md +8 -0
  140. package/llms/components/entity.md +7 -0
  141. package/llms/components/feedback.md +22 -3
  142. package/llms/components/input-group.md +8 -1
  143. package/llms/components/line-chart.md +4 -3
  144. package/llms/components/middle-truncate.md +1 -1
  145. package/llms/components/note.md +8 -0
  146. package/llms/components/notification-badge.md +10 -2
  147. package/llms/components/number-ticker.md +86 -0
  148. package/llms/components/otp-field.md +15 -1
  149. package/llms/components/panel-group.md +7 -0
  150. package/llms/components/panel-section.md +8 -0
  151. package/llms/components/pie-chart.md +6 -4
  152. package/llms/components/pill.md +8 -0
  153. package/llms/components/radar-chart.md +4 -3
  154. package/llms/components/radial-chart.md +6 -4
  155. package/llms/components/radio-card.md +9 -0
  156. package/llms/components/radio.md +7 -0
  157. package/llms/components/relative-time.md +8 -0
  158. package/llms/components/scroller.md +8 -0
  159. package/llms/components/scrub-field.md +8 -0
  160. package/llms/components/search-input.md +7 -0
  161. package/llms/components/show-more.md +9 -0
  162. package/llms/components/side-nav.md +8 -0
  163. package/llms/components/sidebar.md +9 -0
  164. package/llms/components/slider.md +7 -0
  165. package/llms/components/sparkline.md +3 -2
  166. package/llms/components/table.md +7 -0
  167. package/llms/components/tabs.md +20 -2
  168. package/llms/components/theme-selector.md +8 -0
  169. package/llms/components/theme-switcher.md +17 -0
  170. package/llms/components/toggle.md +19 -2
  171. package/llms/components/tool-call.md +115 -0
  172. package/llms/components/tooltip.md +11 -1
  173. package/llms/components/tree.md +8 -0
  174. package/llms/components/video.md +8 -0
  175. package/llms/foundations/motion.md +48 -3
  176. package/llms/reference/utilities.md +23 -1
  177. package/llms/tasks/reviewing-motion.md +14 -0
  178. package/llms-full.txt +955 -49
  179. package/llms.txt +15 -10
  180. package/package.json +1 -1
  181. package/registry.json +2565 -210
  182. package/targets/ios.json +27 -0
package/registry.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://sable.design/registry.schema.json",
3
3
  "name": "@sableui/react",
4
- "version": "0.13.2",
4
+ "version": "0.14.0",
5
5
  "description": "Themeable React components, semantic tokens, layout patterns, presets, and package-owned agent skills. Default theme: Sable.",
6
6
  "engine": {
7
7
  "behavior": "@base-ui/react",
@@ -949,6 +949,12 @@
949
949
  "motion-slide-right",
950
950
  "motion-disclosure",
951
951
  "motion-icon-swap",
952
+ "motion-glide",
953
+ "motion-shake",
954
+ "motion-tick",
955
+ "motion-appear",
956
+ "motion-arrive",
957
+ "motion-size",
952
958
  "motion-reveal",
953
959
  "motion-draw",
954
960
  "motion-spin",
@@ -966,6 +972,7 @@
966
972
  ],
967
973
  "policy": [
968
974
  "data-motion=full",
975
+ "data-motion=subtle",
969
976
  "data-motion=reduced",
970
977
  "data-motion=off",
971
978
  "prefers-reduced-motion"
@@ -2809,6 +2816,12 @@
2809
2816
  "part": "AccordionPanel",
2810
2817
  "why": "Panel height is the only cue that content came from THIS header rather than appearing at the bottom of the page. Snapping it open moves everything below by a screenful with nothing connecting cause to effect."
2811
2818
  },
2819
+ {
2820
+ "slot": "transition",
2821
+ "recipe": "motion-interactive",
2822
+ "part": "Chevron",
2823
+ "why": "The chevron turning is the header's half of the same event as the panel opening. motion-interactive lists `rotate` (what Tailwind's rotate-* writes), so it turns with the panel instead of flipping 180° in one frame beside an easing panel. A custom `indicator` that rotates should carry motion-interactive itself."
2824
+ },
2812
2825
  {
2813
2826
  "slot": "activity",
2814
2827
  "recipe": "none",
@@ -2821,7 +2834,7 @@
2821
2834
  "Arrow keys move roving focus between triggers; Home/End jump to the first/last; loopFocus wraps by default.",
2822
2835
  "Each trigger toggles its panel with Enter/Space; the panel is announced as a labelled region.",
2823
2836
  "A disabled AccordionItem dims and is skipped by keyboard focus while neighbouring items stay interactive.",
2824
- "The chevron rotation is driven by data-[panel-open] and the focus ring by focus-visible — both honor reduced-motion via the consumer's transitions."
2837
+ "The chevron rotation is driven by data-[panel-open] and the focus ring by focus-visible; both ride motion-interactive, so reduced motion stills them with everything else."
2825
2838
  ],
2826
2839
  "examples": [
2827
2840
  {
@@ -2836,7 +2849,7 @@
2836
2849
  },
2837
2850
  {
2838
2851
  "label": "Custom indicator (plus-becomes-×)",
2839
- "code": "<Accordion defaultValue={[\"a\"]}><AccordionItem value=\"a\"><AccordionTrigger indicator={<Icon name=\"plus\" className=\"text-xs text-muted-foreground transition-transform [[data-panel-open]_&]:rotate-45\" />}>Can I cancel anytime?</AccordionTrigger><AccordionPanel>Yes — from billing settings.</AccordionPanel></AccordionItem></Accordion>",
2852
+ "code": "<Accordion defaultValue={[\"a\"]}><AccordionItem value=\"a\"><AccordionTrigger indicator={<Icon name=\"plus\" className=\"text-xs text-muted-foreground motion-interactive [[data-panel-open]_&]:rotate-45\" />}>Can I cancel anytime?</AccordionTrigger><AccordionPanel>Yes — from billing settings.</AccordionPanel></AccordionItem></Accordion>",
2840
2853
  "imports": "import { Accordion, AccordionItem, AccordionPanel, AccordionTrigger, Icon } from \"@sableui/react\""
2841
2854
  }
2842
2855
  ]
@@ -3214,11 +3227,18 @@
3214
3227
  "default": "false for columns; true for horizontal bars",
3215
3228
  "description": "The vertical axis."
3216
3229
  },
3230
+ {
3231
+ "name": "motion",
3232
+ "type": "\"full\" | \"subtle\" | \"off\"",
3233
+ "default": "\"full\"",
3234
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. The arrival (draw-on, rising fill) and the data-change morph are the flourish; the gliding cursor and tooltip are the feedback `subtle` keeps."
3235
+ },
3217
3236
  {
3218
3237
  "name": "animate",
3219
3238
  "type": "boolean",
3220
3239
  "default": "true",
3221
- "description": "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart."
3240
+ "description": "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart. `animate={false}` is `motion=\"subtle\"`.",
3241
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to: no arrival, hover kept) or motion=\"off\""
3222
3242
  },
3223
3243
  {
3224
3244
  "name": "activeIndex",
@@ -3273,7 +3293,7 @@
3273
3293
  "slot": "arrival",
3274
3294
  "recipe": "custom",
3275
3295
  "part": "Area + line",
3276
- "why": "Once on screen, each line draws on (stroke-dashoffset) and its fill rises from the baseline, series staggered — the trend is read as it is drawn. On the motion tokens; reduced motion shows the finished chart."
3296
+ "why": "Once on screen, each line draws on (stroke-dashoffset) and its fill rises from the baseline, series staggered — the trend is read as it is drawn. On the motion tokens; reduced motion shows the finished chart. The arrival is expressive: it plays at the full motion level only, so `motion=\"subtle\"` (or a subtle app policy) shows the finished chart while hover feedback stays."
3277
3297
  },
3278
3298
  {
3279
3299
  "slot": "response",
@@ -3285,7 +3305,7 @@
3285
3305
  "slot": "transition",
3286
3306
  "recipe": "custom",
3287
3307
  "part": "Area + line",
3288
- "why": "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place."
3308
+ "why": "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place. Expressive, like the arrival: under `subtle` new data swaps in place."
3289
3309
  },
3290
3310
  {
3291
3311
  "slot": "activity",
@@ -3476,6 +3496,32 @@
3476
3496
  "overlay-foreground",
3477
3497
  "ring"
3478
3498
  ],
3499
+ "motion": [
3500
+ {
3501
+ "slot": "arrival",
3502
+ "recipe": "none",
3503
+ "part": "Root",
3504
+ "why": "An attachment lands as part of a composer or a message, and that surface owns the entrance. A card that animated in on its own would read as a second, separate event — as if another file had been added."
3505
+ },
3506
+ {
3507
+ "slot": "response",
3508
+ "recipe": "motion-interactive",
3509
+ "part": "Root",
3510
+ "why": "With `onOpen`, the edge steps up under the pointer; it is the only cue that the card itself opens the file, not just its actions. Eased, it reads as the card answering the cursor rather than a border flickering as the pointer crosses a busy composer."
3511
+ },
3512
+ {
3513
+ "slot": "transition",
3514
+ "recipe": "motion-interactive",
3515
+ "part": "Root",
3516
+ "why": "Uploading to failed re-colours the edge and fill without changing the shape. The ease keeps it legible as the same file turning red; snapped, a failed card looks like a different card replaced the one that was sent."
3517
+ },
3518
+ {
3519
+ "slot": "activity",
3520
+ "recipe": "motion-pulse",
3521
+ "part": "Progress",
3522
+ "why": "While uploading with no known `progress`, the bar is indeterminate and pulses (Progress's own recipe); with a number, the fill eases toward each new value. A still bar at a fixed width is indistinguishable from an upload that has stalled."
3523
+ }
3524
+ ],
3479
3525
  "a11y": [
3480
3526
  "The full-card trigger is a real stretched `<button>` with an accessible label, not a click handler on a div — so it is one tab stop, keyboard-activatable, and focus-ringed.",
3481
3527
  "Actions sit above that trigger in the stacking order, which keeps them individually focusable and stops a click on `remove` from also opening the file. Text is only made click-through when a trigger is present, so a filename stays selectable otherwise.",
@@ -4340,11 +4386,18 @@
4340
4386
  "default": "false for columns; true for horizontal bars",
4341
4387
  "description": "The vertical axis."
4342
4388
  },
4389
+ {
4390
+ "name": "motion",
4391
+ "type": "\"full\" | \"subtle\" | \"off\"",
4392
+ "default": "\"full\"",
4393
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. The arrival (bars rising in sequence) and the data-change morph are the flourish; the hover dim and gliding cursor are the feedback `subtle` keeps."
4394
+ },
4343
4395
  {
4344
4396
  "name": "animate",
4345
4397
  "type": "boolean",
4346
4398
  "default": "true",
4347
- "description": "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart."
4399
+ "description": "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart. `animate={false}` is `motion=\"subtle\"`.",
4400
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to: no arrival, hover kept) or motion=\"off\""
4348
4401
  },
4349
4402
  {
4350
4403
  "name": "activeIndex",
@@ -4422,19 +4475,19 @@
4422
4475
  "slot": "arrival",
4423
4476
  "recipe": "custom",
4424
4477
  "part": "Bar",
4425
- "why": "Once on screen, bars rise from the baseline in sequence on the emphasized easing (scale from the zero line) and their labels fade in. On the motion tokens; reduced motion shows the finished chart."
4478
+ "why": "Once on screen, bars rise from the baseline in sequence on the emphasized easing (scale from the zero line) and their labels fade in. On the motion tokens; reduced motion shows the finished chart. The arrival is expressive: it plays at the full motion level only, so `motion=\"subtle\"` (or a subtle app policy) shows the finished chart while hover feedback stays."
4426
4479
  },
4427
4480
  {
4428
4481
  "slot": "response",
4429
4482
  "recipe": "none",
4430
4483
  "part": "Cursor + tooltip",
4431
- "why": "The hovered category gets a band behind it and the other bars dim at once — a lagging highlight reads as a slow chart."
4484
+ "why": "The hovered category gets a band behind it and the other bars dim at once — a lagging highlight reads as a slow chart. The dim mixes each bar toward the surface instead of fading it, so gridlines never strike through a dimmed column and stacked segments stay solid. Essential: `subtle` keeps it."
4432
4485
  },
4433
4486
  {
4434
4487
  "slot": "transition",
4435
4488
  "recipe": "custom",
4436
4489
  "part": "Bar",
4437
- "why": "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place."
4490
+ "why": "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place. Expressive, like the arrival: under `subtle` new data swaps in place."
4438
4491
  },
4439
4492
  {
4440
4493
  "slot": "activity",
@@ -4534,6 +4587,38 @@
4534
4587
  "foreground",
4535
4588
  "ring"
4536
4589
  ],
4590
+ "motion": [
4591
+ {
4592
+ "slot": "arrival",
4593
+ "recipe": "none",
4594
+ "part": "Root",
4595
+ "why": "Fixed app chrome, present from the first paint of every screen. Animating it in on navigation would make each route change look like the app restarting."
4596
+ },
4597
+ {
4598
+ "slot": "response",
4599
+ "recipe": "motion-interactive",
4600
+ "part": "BottomNavItem",
4601
+ "why": "The ink steps up on hover and the focus ring eases in, so a pointer or a keyboard on a tablet gets the same answer as every other control in the app."
4602
+ },
4603
+ {
4604
+ "slot": "response",
4605
+ "recipe": "motion-press",
4606
+ "part": "BottomNavItem",
4607
+ "why": "On a phone there is no hover to say a tap landed, and a route can take a beat to paint. The settle on press is the receipt; without it people tap the tab a second time."
4608
+ },
4609
+ {
4610
+ "slot": "transition",
4611
+ "recipe": "motion-interactive",
4612
+ "part": "BottomNavItem",
4613
+ "why": "Changing tab eases the active ink out of one item and into the next. It is a cross-fade, not a travelling indicator: each item owns its colour, so there is no single mark to glide."
4614
+ },
4615
+ {
4616
+ "slot": "activity",
4617
+ "recipe": "none",
4618
+ "part": "Root",
4619
+ "why": "Navigation has no working state of its own. A slow route shows its pending state in the page it is loading, not in the tab bar."
4620
+ }
4621
+ ],
4537
4622
  "a11y": [
4538
4623
  "BottomNav renders a <nav> landmark.",
4539
4624
  "Each tab is a 44px minimum touch target with an aria-label.",
@@ -4648,6 +4733,32 @@
4648
4733
  "muted-foreground",
4649
4734
  "foreground"
4650
4735
  ],
4736
+ "motion": [
4737
+ {
4738
+ "slot": "arrival",
4739
+ "recipe": "none",
4740
+ "part": "Root",
4741
+ "why": "The trail is page furniture, rendered with the page header. An entrance would replay on every navigation, which is exactly when the reader looks at it to get their bearings."
4742
+ },
4743
+ {
4744
+ "slot": "response",
4745
+ "recipe": "motion-interactive",
4746
+ "part": "BreadcrumbLink",
4747
+ "why": "Ancestors are muted until hovered or focused. Easing the ink makes the trail read as links answering the pointer; a hard snap across a row of small text looks like flicker."
4748
+ },
4749
+ {
4750
+ "slot": "transition",
4751
+ "recipe": "none",
4752
+ "part": "Root",
4753
+ "why": "A breadcrumb has no state of its own. The current page changes by navigation, which replaces the trail; animating between two trails would imply the path itself moved."
4754
+ },
4755
+ {
4756
+ "slot": "activity",
4757
+ "recipe": "none",
4758
+ "part": "Root",
4759
+ "why": "It never works in the background. A route that is still loading says so in the page, not in its address."
4760
+ }
4761
+ ],
4651
4762
  "a11y": [
4652
4763
  "The <nav> carries aria-label=\"Breadcrumb\" so assistive tech announces the landmark.",
4653
4764
  "Crumbs sit in an ordered list (<ol>) to convey hierarchy and position.",
@@ -4958,7 +5069,7 @@
4958
5069
  },
4959
5070
  {
4960
5071
  "guidance": "do",
4961
- "description": "For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`. Text that costs something to produce (a file to fetch) goes in as a function, `useCopy(() => fetch(url).then((r) => r.text()))`, called only on click."
5072
+ "description": "For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`. Text that costs something to produce (a file to fetch) goes in as a function, `useCopy(() => fetch(url).then((r) => r.text()))`, called only on click. Keep both glyphs mounted in a `motion-icon-swap` cell so the copy mark visibly becomes a check (a `copied ? \"check\" : \"copy\"` swap snaps between frames and is easy to miss), and say \"Copied\" in a polite `role=\"status\"` region: the glyph change is silent to a screen reader."
4962
5073
  },
4963
5074
  {
4964
5075
  "guidance": "do",
@@ -5059,7 +5170,7 @@
5059
5170
  "name": "loading",
5060
5171
  "type": "boolean",
5061
5172
  "default": "false",
5062
- "description": "Show a leading Spinner while an action is in flight and refuse presses. Sets aria-busy and aria-disabled, never the native disabled: disabling the focused button sent focus to <body>, so a sheet the action opened had nowhere to return it."
5173
+ "description": "Show a Spinner while an action is in flight and refuse presses. The spinner takes the iconStart slot when there is one; otherwise it lays over the label, which fades out in place, so the button keeps its width. Sets aria-busy and aria-disabled, never the native disabled: disabling the focused button sent focus to <body>, so a sheet the action opened had nowhere to return it."
5063
5174
  },
5064
5175
  {
5065
5176
  "name": "onClick",
@@ -5074,7 +5185,7 @@
5074
5185
  {
5075
5186
  "name": "loadingLabel",
5076
5187
  "type": "ReactNode",
5077
- "description": "Optional text shown in place of children while loading."
5188
+ "description": "Optional text shown in place of children while loading, with the spinner beside it. The width follows the new text, so prefer no loadingLabel where the row must not move."
5078
5189
  },
5079
5190
  {
5080
5191
  "name": "iconStart",
@@ -5152,9 +5263,9 @@
5152
5263
  },
5153
5264
  {
5154
5265
  "slot": "transition",
5155
- "recipe": "none",
5156
- "part": "Root",
5157
- "why": "A button has no state of its own to move between. A control that toggles is a Switch or a Toggle, and those carry the transition."
5266
+ "recipe": "motion-appear",
5267
+ "part": "Spinner",
5268
+ "why": "Going busy is the one state change a button has. The spinner fades in where it mounts — in the icon slot, or laid over a plain label that fades out beneath it and keeps its place — so the width never changes and the row does not shift under the pointer that just pressed it. The label fades back when the work settles. A toggling control is a Switch or a Toggle; those carry their own transition."
5158
5269
  },
5159
5270
  {
5160
5271
  "slot": "activity",
@@ -5195,8 +5306,8 @@
5195
5306
  },
5196
5307
  {
5197
5308
  "label": "Copy to clipboard",
5198
- "code": "const { copy, copied } = useCopy(address)\n<Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Copy address\" onClick={copy}><Icon name={copied ? \"check\" : \"copy\"} /></Button>",
5199
- "imports": "import { Button, Icon, useCopy } from \"@sableui/react\""
5309
+ "code": "const { copy, copied } = useCopy(address)\n<Row gap={1}><Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Copy address\" onClick={copy}><span aria-hidden=\"true\" className=\"motion-icon-swap\"><span data-active={!copied} className=\"inline-flex\"><Icon name=\"copy\" /></span><span data-active={copied} className=\"inline-flex\"><Icon name=\"check\" /></span></span></Button><span role=\"status\" className=\"sr-only\">{copied ? \"Copied\" : \"\"}</span></Row>",
5310
+ "imports": "import { Button, Icon, Row, useCopy } from \"@sableui/react\""
5200
5311
  },
5201
5312
  {
5202
5313
  "label": "As link",
@@ -5435,6 +5546,12 @@
5435
5546
  "default": "false",
5436
5547
  "description": "Drop the card frame (border, fill, padding) so the grid sits flush inside a surface that already has one, such as a Popover. DatePicker renders its Calendar this way."
5437
5548
  },
5549
+ {
5550
+ "name": "motion",
5551
+ "type": "\"full\" | \"subtle\" | \"off\"",
5552
+ "default": "\"full\"",
5553
+ "description": "How much motion this component uses: `full` inherits the app's policy, `subtle` keeps feedback and drops the month slide, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting."
5554
+ },
5438
5555
  {
5439
5556
  "name": "className",
5440
5557
  "type": "string",
@@ -5469,6 +5586,38 @@
5469
5586
  "muted-foreground",
5470
5587
  "ring"
5471
5588
  ],
5589
+ "motion": [
5590
+ {
5591
+ "slot": "arrival",
5592
+ "recipe": "none",
5593
+ "part": "Root",
5594
+ "why": "Inline, it arrives with its form; in a DatePicker the Popover carries the entrance. A grid that animated in by itself would put a second motion on the same opening."
5595
+ },
5596
+ {
5597
+ "slot": "response",
5598
+ "recipe": "motion-interactive",
5599
+ "part": "Day",
5600
+ "why": "Days ease their hover wash, and the month arrows are ghost Buttons on Button's own recipe. Across a seven-column grid the pointer crosses many cells at once, and an eased wash reads as a cursor travelling rather than a grid flashing."
5601
+ },
5602
+ {
5603
+ "slot": "transition",
5604
+ "recipe": "motion-interactive",
5605
+ "part": "Day",
5606
+ "why": "Picking a day eases the primary fill onto it and off the previous one, so a click reads as the selection moving rather than two cells flipping. A range's band is painted on the cells, which carry the same recipe, so the band eases along with its endpoints as the range grows."
5607
+ },
5608
+ {
5609
+ "slot": "activity",
5610
+ "recipe": "none",
5611
+ "part": "Root",
5612
+ "why": "Days are resident data; nothing in the grid loads. Availability fetched per month belongs to the surface around the calendar."
5613
+ },
5614
+ {
5615
+ "slot": "transition",
5616
+ "recipe": "custom",
5617
+ "part": "Month",
5618
+ "why": "Paging months slides the outgoing grid toward the edge it is leaving by and brings the new one in from the other, over a short travel with a fade (react-day-picker's `animate` mode on Sable keyframes in styles.css), so forward and back read as direction rather than a grid swapping in one frame. RDP adds a single class per part, which a recipe utility cannot be; mirrored in RTL. Expressive: Calendar turns `animate` on only at full motion, and an explicit `animate` prop still wins."
5619
+ }
5620
+ ],
5472
5621
  "a11y": [
5473
5622
  "react-day-picker manages the roving-tabindex grid, arrow-key navigation, and ARIA roles/labels for the calendar.",
5474
5623
  "Selected days use bg-primary/text-primary-foreground for AA contrast; today is marked with a dot under the number (not a ring, which is reserved for keyboard focus) rather than color alone.",
@@ -5992,6 +6141,12 @@
5992
6141
  "name": "arrowClassName",
5993
6142
  "type": "string",
5994
6143
  "description": "Class for both prev/next arrow buttons. Merged through cn(), so a radius or shadow utility replaces the default rounded-full shadow-md rather than stacking with it."
6144
+ },
6145
+ {
6146
+ "name": "motion",
6147
+ "type": "\"full\" | \"subtle\" | \"off\"",
6148
+ "default": "\"full\"",
6149
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the glide, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. Under subtle the slides still travel and the active dot snaps; off also cuts between slides and pauses autoplay."
5995
6150
  }
5996
6151
  ],
5997
6152
  "tokensUsed": [
@@ -6002,6 +6157,38 @@
6002
6157
  "foreground",
6003
6158
  "muted-foreground"
6004
6159
  ],
6160
+ "motion": [
6161
+ {
6162
+ "slot": "arrival",
6163
+ "recipe": "none",
6164
+ "part": "Root",
6165
+ "why": "A carousel arrives with its section. Its content already moves on every advance; an entrance on top would make the first view feel like an advance nobody asked for."
6166
+ },
6167
+ {
6168
+ "slot": "response",
6169
+ "recipe": "motion-interactive",
6170
+ "part": "Dot",
6171
+ "why": "The dot's 24px hit area eases its focus ring and the dot brightens under the pointer, which is how a 6px mark tells you it is a button."
6172
+ },
6173
+ {
6174
+ "slot": "transition",
6175
+ "recipe": "custom",
6176
+ "part": "Track",
6177
+ "why": "The slide track's translate is set inline from the measured slide pitch, on --motion-duration-slow and the emphasized easing, and switched off during a drag so the track follows the finger. A recipe cannot carry a measured, drag-interrupted offset. Essential: it is the answer to \"next\", so it plays under subtle."
6178
+ },
6179
+ {
6180
+ "slot": "transition",
6181
+ "recipe": "motion-glide",
6182
+ "part": "Dot",
6183
+ "why": "The active dot stretches to a bar while the one it left shrinks, on one timeline, so the row keeps its length and the mark reads as travelling with the slide. It used to change width unanimated and jumped while the slides beside it glided. Expressive: motion=\"subtle\" snaps it."
6184
+ },
6185
+ {
6186
+ "slot": "activity",
6187
+ "recipe": "none",
6188
+ "part": "Root",
6189
+ "why": "Autoplay's progress is the slides themselves advancing and the play/pause control; a separate timer animation would put a second clock beside the first."
6190
+ }
6191
+ ],
6005
6192
  "a11y": [
6006
6193
  "Region carries aria-roledescription='carousel'; each slide is a group with aria-roledescription='slide' and an 'N of M' label; clones are aria-hidden.",
6007
6194
  "Arrows and dots are real buttons (dots set aria-current); Left/Right arrow keys navigate when the viewport is focused.",
@@ -6028,6 +6215,11 @@
6028
6215
  "label": "Autoplay",
6029
6216
  "code": "<Carousel loop autoplay={4000} counter>{slides}</Carousel>",
6030
6217
  "imports": "import { Carousel } from \"@sableui/react\""
6218
+ },
6219
+ {
6220
+ "label": "Subtle motion",
6221
+ "code": "<Carousel loop motion=\"subtle\" label=\"Gallery\">{slides}</Carousel>",
6222
+ "imports": "import { Carousel } from \"@sableui/react\""
6031
6223
  }
6032
6224
  ]
6033
6225
  },
@@ -6246,6 +6438,12 @@
6246
6438
  "type": "Ref<HTMLTextAreaElement>",
6247
6439
  "description": "Ref to the underlying textarea — for focusing after a send, or on mount, and the extension point for anything that listens to the field. Pass `useCaretTrigger`'s `fieldRef` here for mentions and slash commands (CaretMenu). An Enter another listener already handled (`preventDefault()`) does not send."
6248
6440
  },
6441
+ {
6442
+ "name": "motion",
6443
+ "type": "\"full\" | \"subtle\" | \"off\"",
6444
+ "default": "\"full\"",
6445
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. Under `subtle` the field snaps to its new height and the send/stop glyphs swap without the morph."
6446
+ },
6249
6447
  {
6250
6448
  "name": "...form props",
6251
6449
  "type": "Omit<ComponentPropsWithRef<\"form\">, \"onSubmit\" | \"defaultValue\">",
@@ -6272,6 +6470,38 @@
6272
6470
  "accent",
6273
6471
  "accent-foreground"
6274
6472
  ],
6473
+ "motion": [
6474
+ {
6475
+ "slot": "arrival",
6476
+ "recipe": "none",
6477
+ "part": "Root",
6478
+ "why": "The composer is the fixed foot of a thread, there before anything else is. An entrance would only delay the first keystroke."
6479
+ },
6480
+ {
6481
+ "slot": "response",
6482
+ "recipe": "motion-interactive",
6483
+ "part": "Root",
6484
+ "why": "The surface's focus ring and invalid border ease in, so focusing the field reads as the whole box waking up rather than a frame flicker."
6485
+ },
6486
+ {
6487
+ "slot": "transition",
6488
+ "recipe": "custom",
6489
+ "part": "Field",
6490
+ "why": "As a line breaks or a paste lands, the field eases to its new height (up to its cap) instead of jumping, so the transcript above is visibly pushed rather than teleported. `field-sizing` alone cannot ease — its height stays `auto` — so a small hook measures and pins a pixel height. Expressive and short (`fast`): the text itself lands at once, and `subtle` snaps the box."
6491
+ },
6492
+ {
6493
+ "slot": "transition",
6494
+ "recipe": "motion-icon-swap",
6495
+ "part": "Send button",
6496
+ "why": "Send turning into stop is the moment a reply starts, and stop turning back is the moment it is over. One button changes role in place — the fill eases, the arrow collapses into the square — so it reads as the same control changing what it does, not a different button appearing under the cursor."
6497
+ },
6498
+ {
6499
+ "slot": "activity",
6500
+ "recipe": "motion-spin",
6501
+ "part": "Send button",
6502
+ "why": "Busy with no stop handler, Button's own loading Spinner replaces the arrow, so a send in flight is not mistaken for a disabled one."
6503
+ }
6504
+ ],
6275
6505
  "a11y": [
6276
6506
  "A real `<form>`, so the send button is a submit button and the whole thing behaves as one control to assistive tech and to password managers alike.",
6277
6507
  "Enter never submits while an IME composition is open (`isComposing`), so a CJK reader confirming a candidate word does not mail a fragment of their first sentence.",
@@ -6406,6 +6636,12 @@
6406
6636
  "name": "render",
6407
6637
  "type": "ReactElement | (props, state) => ReactElement",
6408
6638
  "description": "Compose the root with another element through the Base UI render prop."
6639
+ },
6640
+ {
6641
+ "name": "motion",
6642
+ "type": "\"full\" | \"subtle\" | \"off\"",
6643
+ "default": "\"full\"",
6644
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. Under `subtle` the tick fades in instead of drawing itself."
6409
6645
  }
6410
6646
  ],
6411
6647
  "tokensUsed": [
@@ -6415,6 +6651,32 @@
6415
6651
  "primary-foreground",
6416
6652
  "ring"
6417
6653
  ],
6654
+ "motion": [
6655
+ {
6656
+ "slot": "arrival",
6657
+ "recipe": "none",
6658
+ "part": "Root",
6659
+ "why": "It arrives with the form it belongs to. Animating each box in turns a settings list into a title sequence."
6660
+ },
6661
+ {
6662
+ "slot": "response",
6663
+ "recipe": "motion-interactive",
6664
+ "part": "Box",
6665
+ "why": "The box fills with the primary colour as it is checked; easing the fill keeps the mark and the box changing as one event rather than two frames apart."
6666
+ },
6667
+ {
6668
+ "slot": "transition",
6669
+ "recipe": "motion-tick",
6670
+ "part": "Mark",
6671
+ "why": "The check draws itself in from its leading stroke, so checking reads as the box being ticked rather than a glyph appearing. The dash wipes the same way. Unchecking only fades, so clearing a list is quick; under `subtle` checking fades too."
6672
+ },
6673
+ {
6674
+ "slot": "activity",
6675
+ "recipe": "none",
6676
+ "part": "Root",
6677
+ "why": "A checkbox commits instantly. A slow write belongs to the surrounding form's pending state, not the control."
6678
+ }
6679
+ ],
6418
6680
  "a11y": [
6419
6681
  "Base UI renders a hidden native <input type=checkbox> for keyboard, focus, and form semantics.",
6420
6682
  "Space toggles; focus-visible draws a 2px ring in the --ring token.",
@@ -6545,6 +6807,12 @@
6545
6807
  "name": "checked / onCheckedChange / defaultChecked / disabled",
6546
6808
  "type": "Base UI Checkbox.Root props",
6547
6809
  "description": "Standard checkbox state. Without a group-owned value, each card is independent — drive checked/onCheckedChange per item for multi-select."
6810
+ },
6811
+ {
6812
+ "name": "motion",
6813
+ "type": "\"full\" | \"subtle\" | \"off\"",
6814
+ "default": "\"full\"",
6815
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. Under `subtle` the tick fades in instead of drawing itself."
6548
6816
  }
6549
6817
  ],
6550
6818
  "subcomponents": [
@@ -6584,6 +6852,32 @@
6584
6852
  "tone-warning",
6585
6853
  "tone-warning-foreground"
6586
6854
  ],
6855
+ "motion": [
6856
+ {
6857
+ "slot": "arrival",
6858
+ "recipe": "none",
6859
+ "part": "Root",
6860
+ "why": "It arrives with the form it belongs to. Animating each box in turns a settings list into a title sequence."
6861
+ },
6862
+ {
6863
+ "slot": "response",
6864
+ "recipe": "motion-interactive",
6865
+ "part": "Box",
6866
+ "why": "The box fills with the primary colour as it is checked; easing the fill keeps the mark and the box changing as one event rather than two frames apart."
6867
+ },
6868
+ {
6869
+ "slot": "transition",
6870
+ "recipe": "motion-tick",
6871
+ "part": "Mark",
6872
+ "why": "The check draws itself in from its leading stroke, so checking reads as the box being ticked rather than a glyph appearing. The dash wipes the same way. Unchecking only fades, so clearing a list is quick; under `subtle` checking fades too."
6873
+ },
6874
+ {
6875
+ "slot": "activity",
6876
+ "recipe": "none",
6877
+ "part": "Root",
6878
+ "why": "A checkbox commits instantly. A slow write belongs to the surrounding form's pending state, not the control."
6879
+ }
6880
+ ],
6587
6881
  "a11y": [
6588
6882
  "Base UI provides the checkbox role, aria-checked, hidden native input, keyboard toggle, and data-[checked]/data-disabled state.",
6589
6883
  "The whole card is the hit target; focus draws a 2px outline outside the card (not a ring, so the checked tone ring survives focus); Space toggles the focused card.",
@@ -6777,9 +7071,36 @@
6777
7071
  "muted-foreground",
6778
7072
  "foreground"
6779
7073
  ],
7074
+ "motion": [
7075
+ {
7076
+ "slot": "arrival",
7077
+ "recipe": "none",
7078
+ "part": "Root",
7079
+ "why": "Code is reading content and arrives with the page or message it belongs to. An entrance of its own would delay the text someone came to copy."
7080
+ },
7081
+ {
7082
+ "slot": "response",
7083
+ "recipe": "motion-interactive",
7084
+ "part": "Copy button",
7085
+ "why": "The copy button is a quiet ghost icon in the corner of a block. Its hover fill is what tells the pointer it is a control and not decoration."
7086
+ },
7087
+ {
7088
+ "slot": "transition",
7089
+ "recipe": "motion-icon-swap",
7090
+ "part": "Copy glyph",
7091
+ "why": "The copy mark turning into a check is the only proof the clipboard took the text. Snapped between frames it was easy to miss at the edge of a block; the swap reads as one mark being replaced."
7092
+ },
7093
+ {
7094
+ "slot": "activity",
7095
+ "recipe": "none",
7096
+ "part": "Root",
7097
+ "why": "Copying is instant. There is no in-between worth showing, and a spinner for a clipboard write would read as a slow one."
7098
+ }
7099
+ ],
6780
7100
  "a11y": [
6781
7101
  "Code renders inside a <pre><code> for correct semantics and screen-reader handling.",
6782
- "Copy is the ghost icon Button with `useCopy`, which guards clipboard access for SSR safety."
7102
+ "Copy is the ghost icon Button with `useCopy`, which guards clipboard access for SSR safety.",
7103
+ "A successful copy is announced as \"Copied\" through a polite role=status region that is always mounted; the button keeps its label, which names what it does. Both glyphs are aria-hidden."
6783
7104
  ],
6784
7105
  "examples": [
6785
7106
  {
@@ -6901,6 +7222,38 @@
6901
7222
  "foreground",
6902
7223
  "muted-foreground"
6903
7224
  ],
7225
+ "motion": [
7226
+ {
7227
+ "slot": "arrival",
7228
+ "recipe": "none",
7229
+ "part": "Root",
7230
+ "why": "A collapsible is part of the page it sits in, present from first paint. Its trigger is what the reader notices, not an entrance."
7231
+ },
7232
+ {
7233
+ "slot": "response",
7234
+ "recipe": "none",
7235
+ "part": "CollapsibleTrigger",
7236
+ "why": "The trigger is whatever the consumer renders (usually a Button), and that control brings its own response. Adding one here would double it."
7237
+ },
7238
+ {
7239
+ "slot": "transition",
7240
+ "recipe": "motion-disclosure",
7241
+ "part": "CollapsiblePanel",
7242
+ "why": "The height change ties the content that appears to the trigger that was pressed; snapped open, everything below jumps with nothing linking cause to effect."
7243
+ },
7244
+ {
7245
+ "slot": "transition",
7246
+ "recipe": "motion-interactive",
7247
+ "part": "CollapsibleIndicator",
7248
+ "why": "The chevron turns with the panel rather than flipping in one frame: motion-interactive lists `rotate`, the property rotate-180 writes."
7249
+ },
7250
+ {
7251
+ "slot": "activity",
7252
+ "recipe": "none",
7253
+ "part": "Root",
7254
+ "why": "Nothing here is indeterminate; content that loads on open should put its own loader inside the panel."
7255
+ }
7256
+ ],
6904
7257
  "a11y": [
6905
7258
  "Base UI wires aria-expanded on the trigger and aria-controls/id between trigger and panel.",
6906
7259
  "Trigger is a real <button>; toggles on click and Enter/Space.",
@@ -7042,22 +7395,48 @@
7042
7395
  "ring",
7043
7396
  "popover"
7044
7397
  ],
7045
- "a11y": [
7046
- "The trigger's accessible name includes the current value, so a screen reader announces the colour rather than just \"button\" — the swatch itself conveys nothing without sight.",
7047
- "The Transparent entry is a toggle with aria-pressed, because it is a state of the value and not a navigation action.",
7048
- "The hex field is the keyboard path to any colour. The saturation/value area and the sliders are pointer-driven and are NOT keyboard operable — a known gap, and the reason the hex field is never hidden.",
7049
- "The eyedropper is only rendered where the EyeDropper API exists (Chromium), rather than shown and broken elsewhere."
7050
- ],
7051
- "examples": [
7398
+ "motion": [
7052
7399
  {
7053
- "label": "Canvas background",
7054
- "code": "<PropertyRow label=\"Background\">\n <ColorField value={background} onValueChange={setBackground} allowTransparent />\n</PropertyRow>",
7055
- "imports": "import { ColorField, PropertyRow } from \"@sableui/react\""
7400
+ "slot": "arrival",
7401
+ "recipe": "motion-pop",
7402
+ "part": "Popover",
7403
+ "why": "The picker grows out of the swatch it edits (PopoverContent's recipe, its origin on the anchor). In a property panel with a column of swatches, that origin is what tells you which colour you are now editing."
7056
7404
  },
7057
7405
  {
7058
- "label": "With alpha and a palette",
7059
- "code": "<ColorField\n value={tint}\n onValueChange={setTint}\n allowAlpha\n swatches={[\"#f43f5e\", \"#facc15\", \"#22c55e\", \"#3b82f6\", \"#a855f7\"]}\n label=\"Tint\"\n/>",
7060
- "imports": "import { ColorField } from \"@sableui/react\""
7406
+ "slot": "response",
7407
+ "recipe": "motion-interactive",
7408
+ "part": "Swatch",
7409
+ "why": "Preset swatches lift slightly under the pointer. They are 20px chips of colour with no label, and the eased lift is what tells you which one a click will take."
7410
+ },
7411
+ {
7412
+ "slot": "transition",
7413
+ "recipe": "none",
7414
+ "part": "Handles",
7415
+ "why": "The saturation, hue and alpha handles follow the pointer exactly. A handle that trailed the drag would misreport the colour under the cursor, and a swatch or a typed hex moving them is the value changing, which the field should show at once."
7416
+ },
7417
+ {
7418
+ "slot": "activity",
7419
+ "recipe": "none",
7420
+ "part": "Root",
7421
+ "why": "Picking is synchronous. The EyeDropper is the browser's own modal, with its own feedback."
7422
+ }
7423
+ ],
7424
+ "a11y": [
7425
+ "The trigger's accessible name includes the current value, so a screen reader announces the colour rather than just \"button\" — the swatch itself conveys nothing without sight.",
7426
+ "The Transparent entry is a toggle with aria-pressed, because it is a state of the value and not a navigation action.",
7427
+ "The hex field is the keyboard path to any colour. The saturation/value area and the sliders are pointer-driven and are NOT keyboard operable — a known gap, and the reason the hex field is never hidden.",
7428
+ "The eyedropper is only rendered where the EyeDropper API exists (Chromium), rather than shown and broken elsewhere."
7429
+ ],
7430
+ "examples": [
7431
+ {
7432
+ "label": "Canvas background",
7433
+ "code": "<PropertyRow label=\"Background\">\n <ColorField value={background} onValueChange={setBackground} allowTransparent />\n</PropertyRow>",
7434
+ "imports": "import { ColorField, PropertyRow } from \"@sableui/react\""
7435
+ },
7436
+ {
7437
+ "label": "With alpha and a palette",
7438
+ "code": "<ColorField\n value={tint}\n onValueChange={setTint}\n allowAlpha\n swatches={[\"#f43f5e\", \"#facc15\", \"#22c55e\", \"#3b82f6\", \"#a855f7\"]}\n label=\"Tint\"\n/>",
7439
+ "imports": "import { ColorField } from \"@sableui/react\""
7061
7440
  },
7062
7441
  {
7063
7442
  "label": "The value format is the contract",
@@ -7288,6 +7667,50 @@
7288
7667
  "secondary",
7289
7668
  "secondary-foreground"
7290
7669
  ],
7670
+ "motion": [
7671
+ {
7672
+ "slot": "arrival",
7673
+ "recipe": "motion-pop",
7674
+ "part": "ComboboxContent",
7675
+ "why": "The list grows from the field it filters, its origin on Base UI's anchor. With several comboboxes in a form, that is what attaches the open list to the one being typed in."
7676
+ },
7677
+ {
7678
+ "slot": "response",
7679
+ "recipe": "motion-interactive",
7680
+ "part": "ComboboxItem",
7681
+ "why": "A row's ink eases as the pointer or the arrow keys reach it, so moving through a long filtered list reads as continuous movement rather than rows blinking."
7682
+ },
7683
+ {
7684
+ "slot": "response",
7685
+ "recipe": "custom",
7686
+ "part": "Highlight",
7687
+ "why": "One highlight travels to the active row (lib/highlight-glide: a measured translate on the motion tokens) instead of each row painting its own fill. When the list re-filters under the caret, the highlight sliding to the new match is what shows the keyboard target moved."
7688
+ },
7689
+ {
7690
+ "slot": "response",
7691
+ "recipe": "motion-interactive",
7692
+ "part": "Toggle",
7693
+ "why": "The chevron toggle at the field's edge eases its ink on hover; the change is what tells you it is a separate target from the text box."
7694
+ },
7695
+ {
7696
+ "slot": "transition",
7697
+ "recipe": "motion-interactive",
7698
+ "part": "Toggle",
7699
+ "why": "The chevron turns 180° as the list opens and closes (motion-interactive lists `rotate`), in step with the popup, so it reads as one control changing direction."
7700
+ },
7701
+ {
7702
+ "slot": "transition",
7703
+ "recipe": "motion-pop",
7704
+ "part": "ComboboxContent",
7705
+ "why": "Closing shrinks back toward the field, returning the eye to the input that now holds the value."
7706
+ },
7707
+ {
7708
+ "slot": "activity",
7709
+ "recipe": "none",
7710
+ "part": "Root",
7711
+ "why": "Combobox ships no loading part. A list fetched as you type shows a Spinner (motion-spin) in ComboboxEmpty or the content — where the results will land — and only the caller knows a fetch is in flight."
7712
+ }
7713
+ ],
7291
7714
  "a11y": [
7292
7715
  "Base UI provides role=combobox/listbox/option, roving tabindex, arrow-key navigation, and type-to-filter.",
7293
7716
  "Esc and outside-press dismiss; focus returns to the input on close.",
@@ -7582,6 +8005,44 @@
7582
8005
  "accent",
7583
8006
  "accent-foreground"
7584
8007
  ],
8008
+ "motion": [
8009
+ {
8010
+ "slot": "arrival",
8011
+ "recipe": "motion-pop",
8012
+ "part": "Dialog",
8013
+ "why": "The palette opens on DialogContent's recipe over a fading backdrop. It is summoned by a keystroke from nowhere on screen, and the short scale-in is what says a layer arrived rather than the page changing. The inline CommandMenuPanel has no entrance: it is part of the page."
8014
+ },
8015
+ {
8016
+ "slot": "arrival",
8017
+ "recipe": "motion-appear",
8018
+ "part": "Clear button",
8019
+ "why": "The clear button mounts on the first keystroke, beside the caret the eye is already on. Fading in, it reads as the field offering a way back instead of a control blinking into the header. Removal is instant, so an emptied query does not wait on its X."
8020
+ },
8021
+ {
8022
+ "slot": "response",
8023
+ "recipe": "motion-interactive",
8024
+ "part": "CommandItem",
8025
+ "why": "The active row follows both the arrow keys and the pointer, and its fill eases between rows, so scanning results while typing reads as one highlight moving rather than rows flashing in turn."
8026
+ },
8027
+ {
8028
+ "slot": "response",
8029
+ "recipe": "motion-interactive",
8030
+ "part": "CommandMenuTrigger",
8031
+ "why": "The search-shaped trigger eases its ink on hover like the field shell it imitates, so it reads as the field it opens."
8032
+ },
8033
+ {
8034
+ "slot": "transition",
8035
+ "recipe": "motion-pop",
8036
+ "part": "Dialog",
8037
+ "why": "Closing on Enter or Esc shrinks the palette away fast and returns focus where it came from; the exit is the confirmation that the command ran or was dismissed."
8038
+ },
8039
+ {
8040
+ "slot": "activity",
8041
+ "recipe": "none",
8042
+ "part": "List",
8043
+ "why": "Results are filtered synchronously from the items passed in. Async results are the caller's to mark with a Spinner (motion-spin) in the list, where the person is already looking."
8044
+ }
8045
+ ],
7585
8046
  "a11y": [
7586
8047
  "CommandMenuTrigger is a real <button> named by its visible placeholder, with aria-haspopup=dialog and aria-keyshortcuts (Meta+K on macOS, Control+K elsewhere). Its Kbd hint is aria-hidden, so the shortcut is announced once rather than read as part of the name.",
7587
8048
  "The shortcut never fires from inside a text field, textarea, select or contenteditable (Ctrl+K is delete-to-end-of-line in macOS text views), except the palette's own input.",
@@ -7638,6 +8099,223 @@
7638
8099
  }
7639
8100
  ]
7640
8101
  },
8102
+ {
8103
+ "name": "ConfirmButton",
8104
+ "status": "stable",
8105
+ "group": "Actions",
8106
+ "register": "either",
8107
+ "path": "components/confirm-button",
8108
+ "summary": "An inline two-step confirm for a low-stakes destructive or irreversible action: the first press arms it (\"Delete\" → \"Confirm delete?\", destructive), a second press within a few seconds runs it, and leaving it disarms it.",
8109
+ "keywords": [
8110
+ "confirm",
8111
+ "confirmation",
8112
+ "two-step",
8113
+ "press again",
8114
+ "click again",
8115
+ "arm",
8116
+ "delete",
8117
+ "remove",
8118
+ "destructive",
8119
+ "inline confirm",
8120
+ "hold to confirm"
8121
+ ],
8122
+ "usage": {
8123
+ "description": "Use ConfirmButton when an action deserves one beat of second thought but not a modal: deleting a draft, removing a row, revoking one token, clearing a list that can be rebuilt. The question appears on the button the user is already pressing, so confirming costs one more press in the same place and backing out costs nothing.",
8124
+ "bestPractices": [
8125
+ {
8126
+ "guidance": "do",
8127
+ "description": "Keep the idle label a plain verb (\"Delete\", \"Remove\", \"Revoke\") and let the confirm label ask about it (\"Confirm delete?\" by default, or `confirmLabel`). The user should read the consequence on the very control they are about to press again."
8128
+ },
8129
+ {
8130
+ "guidance": "do",
8131
+ "description": "Leave room for it to grow. The button eases wider to fit the question, so give it space at the end of a row or in a table's action column rather than wedging it between fixed-width neighbours."
8132
+ },
8133
+ {
8134
+ "guidance": "do",
8135
+ "description": "Return the promise from `onConfirm` when the action is async. The button stays armed with a spinner until it settles and refuses further presses, so a slow network cannot run it twice."
8136
+ },
8137
+ {
8138
+ "guidance": "dont",
8139
+ "description": "Do not use it for high-stakes or wide-reaching actions — deleting a project, an account, anything other people depend on. Those need the consequence spelled out and a typed or deliberate confirmation; use DestructiveActionModal.",
8140
+ "instead": [
8141
+ "DestructiveActionModal"
8142
+ ]
8143
+ },
8144
+ {
8145
+ "guidance": "dont",
8146
+ "description": "Do not use it where an undo is possible. If the action can be reversed, run it on the first press and offer Undo in a Toast — no question at all is faster than a good question.",
8147
+ "instead": [
8148
+ "Toast"
8149
+ ]
8150
+ },
8151
+ {
8152
+ "guidance": "dont",
8153
+ "description": "Do not use it for a non-destructive primary action (\"Save\", \"Send\"). Asking twice for something harmless teaches people to press twice without reading, which is what defeats the confirm when it matters; use Button.",
8154
+ "instead": [
8155
+ "Button"
8156
+ ]
8157
+ }
8158
+ ]
8159
+ },
8160
+ "related": {
8161
+ "components": [
8162
+ "Button",
8163
+ "DestructiveActionModal",
8164
+ "Toast",
8165
+ "OverflowMenu"
8166
+ ]
8167
+ },
8168
+ "props": [
8169
+ {
8170
+ "name": "children",
8171
+ "type": "ReactNode",
8172
+ "description": "The idle label — the action, e.g. \"Delete\"."
8173
+ },
8174
+ {
8175
+ "name": "confirmLabel",
8176
+ "type": "ReactNode",
8177
+ "default": "\"Confirm <label>?\"",
8178
+ "description": "The label while armed. Defaults to \"Confirm delete?\" for a text label \"Delete\" (first letter lowered) and \"Confirm?\" otherwise. Pass it to localise."
8179
+ },
8180
+ {
8181
+ "name": "onConfirm",
8182
+ "type": "() => unknown",
8183
+ "description": "Runs on the second press while armed. Return a promise to hold the button armed with a spinner until it settles."
8184
+ },
8185
+ {
8186
+ "name": "timeout",
8187
+ "type": "number",
8188
+ "default": "3000",
8189
+ "description": "How long it waits for the second press, in ms. A decision time, so it is not scaled by motion settings."
8190
+ },
8191
+ {
8192
+ "name": "variant",
8193
+ "type": "ButtonVariant",
8194
+ "default": "\"secondary\"",
8195
+ "description": "The idle look — any Button variant."
8196
+ },
8197
+ {
8198
+ "name": "confirmVariant",
8199
+ "type": "ButtonVariant",
8200
+ "default": "\"destructive\"",
8201
+ "description": "The armed look — any Button variant."
8202
+ },
8203
+ {
8204
+ "name": "size",
8205
+ "type": "\"xs\" | \"sm\" | \"md\" | \"cta\"",
8206
+ "default": "\"md\"",
8207
+ "description": "A labelled Button size. Icon-only sizes are excluded: the label is the interaction."
8208
+ },
8209
+ {
8210
+ "name": "icon",
8211
+ "type": "ReactNode",
8212
+ "description": "A leading icon kept through both states, e.g. `<Icon name=\"trash\" />`."
8213
+ },
8214
+ {
8215
+ "name": "disabled",
8216
+ "type": "boolean",
8217
+ "default": "false",
8218
+ "description": "Disables it; disabling while armed also disarms."
8219
+ },
8220
+ {
8221
+ "name": "confirmHint",
8222
+ "type": "string",
8223
+ "default": "\"Press again to confirm.\"",
8224
+ "description": "Read to assistive tech after the armed label when it arms. Override to localise."
8225
+ },
8226
+ {
8227
+ "name": "motion",
8228
+ "type": "\"full\" | \"subtle\" | \"off\"",
8229
+ "default": "\"full\"",
8230
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. The width easing and the draining countdown are the flourish; the label cross-fade and colour change stay."
8231
+ }
8232
+ ],
8233
+ "variants": {
8234
+ "variant": [
8235
+ "primary",
8236
+ "secondary",
8237
+ "ghost",
8238
+ "outline",
8239
+ "destructive",
8240
+ "link",
8241
+ "floating",
8242
+ "field",
8243
+ "on-color",
8244
+ "on-color-outline",
8245
+ "on-color-ghost"
8246
+ ],
8247
+ "confirmVariant": [
8248
+ "destructive",
8249
+ "primary",
8250
+ "secondary",
8251
+ "outline"
8252
+ ],
8253
+ "size": [
8254
+ "xs",
8255
+ "sm",
8256
+ "md",
8257
+ "cta"
8258
+ ]
8259
+ },
8260
+ "tokensUsed": [
8261
+ "secondary",
8262
+ "secondary-foreground",
8263
+ "destructive",
8264
+ "destructive-foreground",
8265
+ "ring"
8266
+ ],
8267
+ "motion": [
8268
+ {
8269
+ "slot": "arrival",
8270
+ "recipe": "none",
8271
+ "part": "Root",
8272
+ "why": "It is a button in a row of actions, and arrives with that row. Its moment is the arming, not the mount."
8273
+ },
8274
+ {
8275
+ "slot": "response",
8276
+ "recipe": "motion-press",
8277
+ "part": "Root",
8278
+ "why": "Inherited from Button with motion-interactive. The press settle confirms the first press landed even before the label has finished changing — which is what stops a second, accidental press."
8279
+ },
8280
+ {
8281
+ "slot": "transition",
8282
+ "recipe": "motion-size",
8283
+ "part": "Root",
8284
+ "why": "Arming changes the label, so the button's width changes. Easing it makes the button visibly grow into the question and its neighbours travel, instead of the row jumping under the pointer that is about to press again. useAutoSize measures the content and writes a px width for motion-size to ease between (a content-sized box is width:auto before and after, which cannot transition). The labels cross-fade in one cell so there is never a frame with both or neither."
8285
+ },
8286
+ {
8287
+ "slot": "activity",
8288
+ "recipe": "custom",
8289
+ "part": "Countdown",
8290
+ "why": "While armed, a thin line drains along the foot of the button over the timeout — the only way to see that the question expires. A transition on scale, timed by the `timeout` prop and multiplied by the motion scale and expressive factor, so under reduced motion or `subtle` it is absent rather than frozen full."
8291
+ }
8292
+ ],
8293
+ "a11y": [
8294
+ "A native <button> through Button. Focus stays on it through both presses; Enter and Space confirm exactly as a click does.",
8295
+ "Only the current label is in the accessible name — the other is aria-hidden — so the name changes to the question while armed.",
8296
+ "Arming is also announced through a polite role=status region outside the button (\"Confirm delete? Press again to confirm.\"), because a focused button's name change is not read by every screen reader.",
8297
+ "Escape disarms and is consumed, so it does not also close the dialog or popover around it. Focus leaving, a press elsewhere, or the timeout disarm it too.",
8298
+ "A second press within 300ms of arming is ignored: a double-click is a habit, not a decision.",
8299
+ "Under reduced motion the label still changes and the colour still turns; only the width easing and countdown are dropped."
8300
+ ],
8301
+ "examples": [
8302
+ {
8303
+ "label": "Delete a row",
8304
+ "code": "<ConfirmButton icon={<Icon name=\"trash\" />} onConfirm={() => removeRow(id)}>Delete</ConfirmButton>",
8305
+ "imports": "import { ConfirmButton, Icon } from \"@sableui/react\""
8306
+ },
8307
+ {
8308
+ "label": "Custom question, longer wait",
8309
+ "code": "<ConfirmButton variant=\"ghost\" size=\"sm\" confirmLabel=\"Revoke this key?\" timeout={5000} onConfirm={revokeKey}>Revoke</ConfirmButton>",
8310
+ "imports": "import { ConfirmButton } from \"@sableui/react\""
8311
+ },
8312
+ {
8313
+ "label": "Async confirm",
8314
+ "code": "<ConfirmButton onConfirm={() => api.deleteDraft(draft.id)}>Discard draft</ConfirmButton>",
8315
+ "imports": "import { ConfirmButton } from \"@sableui/react\""
8316
+ }
8317
+ ]
8318
+ },
7641
8319
  {
7642
8320
  "name": "ContextCard",
7643
8321
  "status": "stable",
@@ -8042,26 +8720,58 @@
8042
8720
  "foreground",
8043
8721
  "ring"
8044
8722
  ],
8045
- "a11y": [
8046
- "Sorted columns set aria-sort=ascending/descending on the header cell.",
8047
- "Sortable headers are real <button>s with a focus-visible ring, keyboard-operable.",
8048
- "Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure."
8049
- ],
8050
- "examples": [
8723
+ "motion": [
8051
8724
  {
8052
- "label": "Checkbox selection with a bulk-action bar",
8053
- "code": "const [selected, setSelected] = useState<Set<string | number>>(new Set())\n\n<Stack gap={2}>\n\t{/* Mounted from the start, so a screen reader hears each change. */}\n\t<span role=\"status\" className=\"sr-only\">{selected.size ? `${selected.size} of ${tickets.length} selected` : \"\"}</span>\n\t{selected.size ? (\n\t\t<Toolbar aria-label=\"Bulk actions\">\n\t\t\t<span className=\"text-label-md\">{selected.size} of {tickets.length} selected</span>\n\t\t\t<Button size=\"sm\" variant=\"secondary\" onClick={() => resolve(selected)}>Resolve</Button>\n\t\t</Toolbar>\n\t) : null}\n\t<DataTable\n\t\tdata={tickets}\n\t\trowKey={(t) => t.id}\n\t\tcolumns={columns}\n\t\tselectedKeys={selected}\n\t\tonSelectedKeysChange={setSelected}\n\t\trowSelectionLabel={(t) => `Select ${t.subject}`}\n\t\tselectAllLabel={(n) => `Select all ${n} tickets shown`}\n\t/>\n</Stack>",
8054
- "imports": "import { Button, DataTable, Stack, Toolbar } from \"@sableui/react\""
8725
+ "slot": "arrival",
8726
+ "recipe": "none",
8727
+ "part": "Root",
8728
+ "why": "Rows render with the page or the query that produced them. Staggering rows in delays reading a table someone opened to scan."
8055
8729
  },
8056
8730
  {
8057
- "label": "Sortable columns",
8058
- "code": "<DataTable data={users} columns={[{ key: \"name\", header: \"Name\", sortable: true }, { key: \"role\", header: \"Role\" }, { key: \"joined\", header: \"Joined\", sortable: true, sortValue: (u) => u.joinedAt, cell: (u) => formatDate(u.joinedAt) }]} />",
8059
- "imports": "import { DataTable } from \"@sableui/react\""
8731
+ "slot": "response",
8732
+ "recipe": "motion-interactive",
8733
+ "part": "Row",
8734
+ "why": "Body rows ease to a muted wash under the pointer (TableRow's recipe), which keeps the eye on the right line across a wide row of numbers. Sortable headers are ghost Buttons and answer on Button's recipe."
8060
8735
  },
8061
8736
  {
8062
- "label": "Clickable rows",
8063
- "code": "<DataTable data={posts} columns={cols} onRowClick={(p) => navigate(`/post/${p.id}`)} rowKey={(p) => p.id} />",
8064
- "imports": "import { DataTable } from \"@sableui/react\""
8737
+ "slot": "transition",
8738
+ "recipe": "motion-interactive",
8739
+ "part": "Sort chevron",
8740
+ "why": "One chevron turns to show direction instead of two glyphs swapping, so ascending to descending reads as the same column reversing. Its opacity rises from the resting 60% as the column becomes the sort."
8741
+ },
8742
+ {
8743
+ "slot": "transition",
8744
+ "recipe": "motion-interactive",
8745
+ "part": "Row",
8746
+ "why": "A checked row's fill eases in across its cells, so selecting by checkbox reads as the row being picked rather than a band stamped over it."
8747
+ },
8748
+ {
8749
+ "slot": "activity",
8750
+ "recipe": "none",
8751
+ "part": "Root",
8752
+ "why": "DataTable has no loading state. While data is fetched, render Skeleton rows (motion-shimmer) in its place — only the caller knows a fetch is in flight."
8753
+ }
8754
+ ],
8755
+ "a11y": [
8756
+ "Sorted columns set aria-sort=ascending/descending on the header cell.",
8757
+ "Sortable headers are real <button>s with a focus-visible ring, keyboard-operable.",
8758
+ "Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure."
8759
+ ],
8760
+ "examples": [
8761
+ {
8762
+ "label": "Checkbox selection with a bulk-action bar",
8763
+ "code": "const [selected, setSelected] = useState<Set<string | number>>(new Set())\n\n<Stack gap={2}>\n\t{/* Mounted from the start, so a screen reader hears each change. */}\n\t<span role=\"status\" className=\"sr-only\">{selected.size ? `${selected.size} of ${tickets.length} selected` : \"\"}</span>\n\t{selected.size ? (\n\t\t<Toolbar aria-label=\"Bulk actions\">\n\t\t\t<span className=\"text-label-md\">{selected.size} of {tickets.length} selected</span>\n\t\t\t<Button size=\"sm\" variant=\"secondary\" onClick={() => resolve(selected)}>Resolve</Button>\n\t\t</Toolbar>\n\t) : null}\n\t<DataTable\n\t\tdata={tickets}\n\t\trowKey={(t) => t.id}\n\t\tcolumns={columns}\n\t\tselectedKeys={selected}\n\t\tonSelectedKeysChange={setSelected}\n\t\trowSelectionLabel={(t) => `Select ${t.subject}`}\n\t\tselectAllLabel={(n) => `Select all ${n} tickets shown`}\n\t/>\n</Stack>",
8764
+ "imports": "import { Button, DataTable, Stack, Toolbar } from \"@sableui/react\""
8765
+ },
8766
+ {
8767
+ "label": "Sortable columns",
8768
+ "code": "<DataTable data={users} columns={[{ key: \"name\", header: \"Name\", sortable: true }, { key: \"role\", header: \"Role\" }, { key: \"joined\", header: \"Joined\", sortable: true, sortValue: (u) => u.joinedAt, cell: (u) => formatDate(u.joinedAt) }]} />",
8769
+ "imports": "import { DataTable } from \"@sableui/react\""
8770
+ },
8771
+ {
8772
+ "label": "Clickable rows",
8773
+ "code": "<DataTable data={posts} columns={cols} onRowClick={(p) => navigate(`/post/${p.id}`)} rowKey={(p) => p.id} />",
8774
+ "imports": "import { DataTable } from \"@sableui/react\""
8065
8775
  },
8066
8776
  {
8067
8777
  "label": "Rich status column",
@@ -9304,6 +10014,32 @@
9304
10014
  "muted-foreground",
9305
10015
  "ring"
9306
10016
  ],
10017
+ "motion": [
10018
+ {
10019
+ "slot": "arrival",
10020
+ "recipe": "none",
10021
+ "part": "Root",
10022
+ "why": "Rows arrive with the list they belong to. Entering one at a time is a stagger, and a stagger on a list someone opened to scan is a delay."
10023
+ },
10024
+ {
10025
+ "slot": "response",
10026
+ "recipe": "motion-interactive",
10027
+ "part": "Root",
10028
+ "why": "Only the interactive row reacts: its hover fill, a step off the surface it sits on, eases in. A static row never fakes clickability, and an interactive one answers the pointer without flicker as it moves down a list."
10029
+ },
10030
+ {
10031
+ "slot": "transition",
10032
+ "recipe": "motion-interactive",
10033
+ "part": "Root",
10034
+ "why": "`selected` eases the fill onto the current row of a list–detail view, so opening another record reads as the selection moving rather than two rows flashing."
10035
+ },
10036
+ {
10037
+ "slot": "activity",
10038
+ "recipe": "none",
10039
+ "part": "Root",
10040
+ "why": "A record row has nothing of its own to wait for. A row whose action is pending puts a loading Button in `action`."
10041
+ }
10042
+ ],
9307
10043
  "a11y": [
9308
10044
  "Default rows are presentational layout; semantics come from the content you place in title/subtitle/action.",
9309
10045
  "interactive rows render a real <button> around the leading visual and text, with its ::after stretched over the whole row — native focus, a focus-visible ring, and Enter/Space. The action slot sits outside that button and above the overlay, so a Button there stays valid HTML and keeps its own clicks.",
@@ -9330,7 +10066,7 @@
9330
10066
  "group": "Overlays",
9331
10067
  "register": "page",
9332
10068
  "path": "components/feedback",
9333
- "summary": "A compact feedback popover. Composes Popover + Textarea + Button + Toggle into a rating row plus a message field; submitting reports the rating + message and closes the popover.",
10069
+ "summary": "A compact feedback popover. Composes Popover + Textarea + Button + Toggle into a rating row plus a message field; submitting reports the rating + message, shows a brief drawn-check thanks, and closes the popover.",
9334
10070
  "keywords": [
9335
10071
  "feedback",
9336
10072
  "sentiment",
@@ -9399,7 +10135,19 @@
9399
10135
  {
9400
10136
  "name": "onSubmit",
9401
10137
  "type": "(data: { rating?: string; message: string }) => void",
9402
- "description": "Called with the selected rating + message when the form is submitted."
10138
+ "description": "Called with the selected rating + message when the form is submitted. The thanks shows right away; send the report in the background."
10139
+ },
10140
+ {
10141
+ "name": "successMessage",
10142
+ "type": "string",
10143
+ "default": "\"Thanks for the feedback.\"",
10144
+ "description": "The line shown, and read out, for about 1.2s after sending, before the popover closes on its own."
10145
+ },
10146
+ {
10147
+ "name": "motion",
10148
+ "type": "\"full\" | \"subtle\" | \"off\"",
10149
+ "default": "\"full\"",
10150
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. The drawn check is the flourish; the thanks line and its dwell stay."
9403
10151
  }
9404
10152
  ],
9405
10153
  "tokensUsed": [
@@ -9412,14 +10160,43 @@
9412
10160
  "muted-foreground",
9413
10161
  "accent",
9414
10162
  "accent-foreground",
9415
- "ring"
10163
+ "ring",
10164
+ "success"
10165
+ ],
10166
+ "motion": [
10167
+ {
10168
+ "slot": "arrival",
10169
+ "recipe": "motion-pop",
10170
+ "part": "Popover",
10171
+ "why": "The form opens from its trigger, so the eye goes from the button it pressed to the field it should type in. Popover owns the recipe."
10172
+ },
10173
+ {
10174
+ "slot": "response",
10175
+ "recipe": "motion-interactive",
10176
+ "part": "Rating toggles",
10177
+ "why": "The rating row is a choice among a few similar marks. The eased selected fill is what makes the pick read as made, not just hovered."
10178
+ },
10179
+ {
10180
+ "slot": "transition",
10181
+ "recipe": "motion-tick",
10182
+ "part": "Thanks",
10183
+ "why": "Sending is the one moment the person needs to know their words arrived. The form fades out in place and a check draws itself in over it, then the popover closes. Without it the popover just vanished, which looks the same as the form being dismissed or the message lost."
10184
+ },
10185
+ {
10186
+ "slot": "activity",
10187
+ "recipe": "none",
10188
+ "part": "Root",
10189
+ "why": "The thanks shows as soon as the form is sent; delivery is the app's background work. A spinner for a one-line report would make a quick, optional gesture feel heavy."
10190
+ }
9416
10191
  ],
9417
10192
  "a11y": [
9418
10193
  "Trigger opens the popover on press; Base UI manages focus into the form and restores it to the trigger on close.",
9419
10194
  "The rating row is a Base UI ToggleGroup with arrow-key roving focus; each toggle carries an aria-label.",
9420
10195
  "The form is named by its heading (aria-labelledby), so it is announced as \"Send feedback\" rather than an unnamed form.",
9421
10196
  "The message field carries an aria-label matching its placeholder.",
9422
- "Submit is disabled until there is a rating or a non-whitespace message. Either alone is a complete answer."
10197
+ "Submit is disabled until there is a rating or a non-whitespace message. Either alone is a complete answer.",
10198
+ "On send, focus moves to the thanks line (tabIndex=-1), which reads it out, and the form becomes inert so focus cannot land in invisible fields. The line is not also a live region, which read it twice. When the popover closes, Base UI returns focus to the trigger.",
10199
+ "The thanks dwell (about 1.2s) is a reading time, not motion, so it is the same under reduced motion."
9423
10200
  ],
9424
10201
  "examples": [
9425
10202
  {
@@ -9431,6 +10208,11 @@
9431
10208
  "label": "Custom labels",
9432
10209
  "code": "<Feedback label=\"Tell us\" submitLabel=\"Send it\" placeholder=\"Describe the issue…\" />",
9433
10210
  "imports": "import { Feedback } from \"@sableui/react\""
10211
+ },
10212
+ {
10213
+ "label": "Custom thanks",
10214
+ "code": "<Feedback successMessage=\"Got it — thank you.\" onSubmit={({ rating, message }) => report(rating, message)} />",
10215
+ "imports": "import { Feedback } from \"@sableui/react\""
9434
10216
  }
9435
10217
  ]
9436
10218
  },
@@ -10832,6 +11614,32 @@
10832
11614
  "muted-foreground",
10833
11615
  "foreground"
10834
11616
  ],
11617
+ "motion": [
11618
+ {
11619
+ "slot": "arrival",
11620
+ "recipe": "none",
11621
+ "part": "Root",
11622
+ "why": "It arrives with the form it belongs to. A field that animated in on its own would be briefly unusable for no stated reason."
11623
+ },
11624
+ {
11625
+ "slot": "response",
11626
+ "recipe": "motion-interactive",
11627
+ "part": "Root",
11628
+ "why": "The shell owns one focus ring for the input and its addons and eases it in, so focusing the text, or tabbing to a copy button inside the field, reads as the one field taking focus."
11629
+ },
11630
+ {
11631
+ "slot": "transition",
11632
+ "recipe": "motion-interactive",
11633
+ "part": "Root",
11634
+ "why": "An invalid value turns the shell's edge destructive on the same ease, so the error lands as the field changing state rather than a red box swapping in."
11635
+ },
11636
+ {
11637
+ "slot": "activity",
11638
+ "recipe": "none",
11639
+ "part": "Root",
11640
+ "why": "A field does no work of its own. A pending check (an availability lookup) puts a Spinner in `trailing`, which is where its answer will appear."
11641
+ }
11642
+ ],
10835
11643
  "a11y": [
10836
11644
  "The Input stays the focusable control; the group draws the focus ring around the whole field while it is focused.",
10837
11645
  "Inside a Field, the label, description and error are wired to the Input itself, not the group.",
@@ -10851,7 +11659,7 @@
10851
11659
  },
10852
11660
  {
10853
11661
  "label": "Copy button",
10854
- "code": "<Field label=\"API key\"><InputGroup trailing={<Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Copy key\" onClick={copy}><Icon name={copied ? \"check\" : \"copy\"} /></Button>}><Input readOnly value={key} /></InputGroup></Field>",
11662
+ "code": "<Field label=\"API key\"><InputGroup trailing={<Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Copy key\" onClick={copy}><span aria-hidden=\"true\" className=\"motion-icon-swap\"><span data-active={!copied} className=\"inline-flex\"><Icon name=\"copy\" /></span><span data-active={copied} className=\"inline-flex\"><Icon name=\"check\" /></span></span></Button>}><Input readOnly value={key} /></InputGroup></Field>",
10855
11663
  "imports": "import { Button, Field, Icon, Input, InputGroup } from \"@sableui/react\""
10856
11664
  },
10857
11665
  {
@@ -11190,11 +11998,18 @@
11190
11998
  "default": "false for columns; true for horizontal bars",
11191
11999
  "description": "The vertical axis."
11192
12000
  },
12001
+ {
12002
+ "name": "motion",
12003
+ "type": "\"full\" | \"subtle\" | \"off\"",
12004
+ "default": "\"full\"",
12005
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. The arrival (lines drawing on) and the data-change morph are the flourish; the gliding cursor, dots and tooltip are the feedback `subtle` keeps."
12006
+ },
11193
12007
  {
11194
12008
  "name": "animate",
11195
12009
  "type": "boolean",
11196
12010
  "default": "true",
11197
- "description": "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart."
12011
+ "description": "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart. `animate={false}` is `motion=\"subtle\"`.",
12012
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to: no arrival, hover kept) or motion=\"off\""
11198
12013
  },
11199
12014
  {
11200
12015
  "name": "activeIndex",
@@ -11255,7 +12070,7 @@
11255
12070
  "slot": "arrival",
11256
12071
  "recipe": "custom",
11257
12072
  "part": "Line",
11258
- "why": "Once on screen each line draws on from the first point (stroke-dashoffset), series staggered, then the dots and labels fade in. On the motion tokens; reduced motion shows the finished chart."
12073
+ "why": "Once on screen each line draws on from the first point (stroke-dashoffset), series staggered, then the dots and labels fade in. On the motion tokens; reduced motion shows the finished chart. The arrival is expressive: it plays at the full motion level only, so `motion=\"subtle\"` (or a subtle app policy) shows the finished chart while hover feedback stays."
11259
12074
  },
11260
12075
  {
11261
12076
  "slot": "response",
@@ -11267,7 +12082,7 @@
11267
12082
  "slot": "transition",
11268
12083
  "recipe": "custom",
11269
12084
  "part": "Line + dots",
11270
- "why": "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place."
12085
+ "why": "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place. Expressive, like the arrival: under `subtle` new data swaps in place."
11271
12086
  },
11272
12087
  {
11273
12088
  "slot": "activity",
@@ -13009,7 +13824,7 @@
13009
13824
  },
13010
13825
  {
13011
13826
  "label": "Copyable value",
13012
- "code": "const { copy, copied } = useCopy(commitHash)\n<Row gap={1}><MiddleTruncate text={commitHash} /><Button variant=\"ghost\" size=\"icon-xs\" aria-label=\"Copy commit hash\" onClick={copy}><Icon name={copied ? \"check\" : \"copy\"} /></Button></Row>",
13827
+ "code": "const { copy, copied } = useCopy(commitHash)\n<Row gap={1}><MiddleTruncate text={commitHash} /><Button variant=\"ghost\" size=\"icon-xs\" aria-label=\"Copy commit hash\" onClick={copy}><span aria-hidden=\"true\" className=\"motion-icon-swap\"><span data-active={!copied} className=\"inline-flex\"><Icon name=\"copy\" /></span><span data-active={copied} className=\"inline-flex\"><Icon name=\"check\" /></span></span></Button><span role=\"status\" className=\"sr-only\">{copied ? \"Copied\" : \"\"}</span></Row>",
13013
13828
  "imports": "import { Button, Icon, MiddleTruncate, Row, useCopy } from \"@sableui/react\""
13014
13829
  }
13015
13830
  ]
@@ -13305,6 +14120,38 @@
13305
14120
  "success",
13306
14121
  "destructive"
13307
14122
  ],
14123
+ "motion": [
14124
+ {
14125
+ "slot": "arrival",
14126
+ "recipe": "none",
14127
+ "part": "Root",
14128
+ "why": "A note is part of the content it annotates and renders with it. A callout that animated in would pull the eye before the reader has reached it in the flow."
14129
+ },
14130
+ {
14131
+ "slot": "response",
14132
+ "recipe": "motion-interactive",
14133
+ "part": "Action",
14134
+ "why": "The action's focus ring eases in, and its underline thickens on hover. Inside a block of guidance, that is what separates the one thing to do from the prose around it."
14135
+ },
14136
+ {
14137
+ "slot": "response",
14138
+ "recipe": "motion-press",
14139
+ "part": "Action",
14140
+ "why": "A note's action usually navigates or opens something; the small settle on press is the only confirmation before the next surface paints."
14141
+ },
14142
+ {
14143
+ "slot": "transition",
14144
+ "recipe": "none",
14145
+ "part": "Root",
14146
+ "why": "A note has no states to move between: its tone is fixed when it renders. A note that changes tone is a new message, and should arrive as one."
14147
+ },
14148
+ {
14149
+ "slot": "activity",
14150
+ "recipe": "none",
14151
+ "part": "Root",
14152
+ "why": "Static guidance. Work in progress belongs in a Progress or a Spinner, not in a callout."
14153
+ }
14154
+ ],
13308
14155
  "a11y": [
13309
14156
  "Rendered with role=\"note\" so assistive tech can announce it as an aside.",
13310
14157
  "Color is never the sole signal — the leading icon and optional title reinforce severity."
@@ -13338,7 +14185,7 @@
13338
14185
  "group": "Badges & pills",
13339
14186
  "register": "either",
13340
14187
  "path": "components/notification-badge",
13341
- "summary": "An unread indicator. Renders a bare dot or a count pill (capped as \"{max}+\"), or nothing when there's nothing to show. overlay pins it to the corner of a relative host; it is aria-hidden, so the host's name carries the count.",
14188
+ "summary": "An unread indicator. Renders a bare dot or a count pill (capped as \"{max}+\"), or nothing when there's nothing to show. A changing count rolls to its new number, and a badge that appears after being empty pops in. overlay pins it to the corner of a relative host; it is aria-hidden, so the host's name carries the count.",
13342
14189
  "keywords": [
13343
14190
  "notification",
13344
14191
  "badge",
@@ -13373,7 +14220,8 @@
13373
14220
  "related": {
13374
14221
  "components": [
13375
14222
  "Badge",
13376
- "StatusDot"
14223
+ "StatusDot",
14224
+ "NumberTicker"
13377
14225
  ]
13378
14226
  },
13379
14227
  "props": [
@@ -13411,6 +14259,12 @@
13411
14259
  "type": "boolean",
13412
14260
  "default": "false",
13413
14261
  "description": "Pin to the top-end corner of the nearest relative host (an icon button) — one placement for every caller instead of hand-tuned absolute offsets."
14262
+ },
14263
+ {
14264
+ "name": "motion",
14265
+ "type": "\"full\" | \"subtle\" | \"off\"",
14266
+ "default": "\"full\"",
14267
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. Under `subtle` the count updates in place and the badge fades in without the pop."
13414
14268
  }
13415
14269
  ],
13416
14270
  "variants": {
@@ -13437,6 +14291,32 @@
13437
14291
  "secondary",
13438
14292
  "secondary-foreground"
13439
14293
  ],
14294
+ "motion": [
14295
+ {
14296
+ "slot": "arrival",
14297
+ "recipe": "motion-appear",
14298
+ "part": "root",
14299
+ "why": "A badge that shows up after being empty is the news — something just arrived — so it fades and scales in where it sits instead of popping into existence. Never on first paint: a badge that mounts with a count is content, and a page of them popping on load is noise."
14300
+ },
14301
+ {
14302
+ "slot": "response",
14303
+ "recipe": "none",
14304
+ "part": "root",
14305
+ "why": "It is decorative and not interactive; the host it sits on answers the pointer."
14306
+ },
14307
+ {
14308
+ "slot": "transition",
14309
+ "recipe": "custom",
14310
+ "part": "Count",
14311
+ "why": "The count rolls to its new number through NumberTicker (expressive), so 3 → 4 reads as one more arriving rather than a different badge. Past the cap the ticker holds at max with a static \"+\", so a count climbing beyond 99 does not keep rolling a badge that has not changed."
14312
+ },
14313
+ {
14314
+ "slot": "activity",
14315
+ "recipe": "none",
14316
+ "part": "root",
14317
+ "why": "An unread count has no working state."
14318
+ }
14319
+ ],
13440
14320
  "a11y": [
13441
14321
  "The badge is aria-hidden: a count rendered on a control is not associated with it, so the host needs an accessible name that includes the count — \"Notifications, 3 unread\" — or a screen-reader user hears \"Notifications, button\" and never learns there is anything new. Pass aria-hidden={false} only for a standalone badge whose number is the information.",
13442
14322
  "A dot carries no number and no words: on its own it tells a sighted user only that something changed, and tells everyone else nothing. Pair it with a label that says what is new, and never make its colour the only difference between two states."
@@ -13569,137 +14449,317 @@
13569
14449
  ]
13570
14450
  },
13571
14451
  {
13572
- "name": "OtpField",
14452
+ "name": "NumberTicker",
13573
14453
  "status": "stable",
13574
- "group": "Forms",
14454
+ "group": "Data display",
13575
14455
  "register": "either",
13576
- "path": "components/otp-field",
13577
- "summary": "One-time-code entry over Base UI's OTP Field — one slot per character, SMS autofill on the first slot, paste fills every slot, and only one tab stop for the whole code.",
14456
+ "path": "components/number-ticker",
14457
+ "summary": "A number that rolls to its new value digit by digit, like an odometer. Formatted with Intl.NumberFormat (currency, percent, compact, any locale); separators, signs and units hold still while each digit column turns, and a digit that appears or goes away eases its width so nothing around it jumps. At rest on first paint; under subtle or reduced motion the value simply updates.",
13578
14458
  "keywords": [
13579
- "otp",
13580
- "one-time code",
13581
- "one-time password",
13582
- "verification code",
13583
- "2fa",
13584
- "two-factor",
13585
- "mfa",
13586
- "pin",
13587
- "passcode",
13588
- "input otp"
14459
+ "number",
14460
+ "counter",
14461
+ "odometer",
14462
+ "rolling number",
14463
+ "animated number",
14464
+ "ticker",
14465
+ "count up",
14466
+ "stat",
14467
+ "kpi",
14468
+ "metric",
14469
+ "price"
13589
14470
  ],
13590
14471
  "usage": {
13591
- "description": "Use OtpField for a short, fixed-length code the user copies from somewhere else — an SMS or email verification code, an authenticator app, a device pairing PIN. The slots show how long the code is and where the user is in it, which a plain Input cannot.",
14472
+ "description": "Use NumberTicker for a figure that changes in place while someone is looking at it — a dashboard total after a filter changes, a live count, a price as a plan's options change, a score. The roll says \"this number moved, and by roughly this much\"; the reader sees the change happen instead of noticing, later, that a number is different.",
13592
14473
  "bestPractices": [
13593
14474
  {
13594
14475
  "guidance": "do",
13595
- "description": "Set `length` to the exact length the server sends, and verify in `onValueComplete` (or with `autoSubmit` inside a form). Most users paste or autofill the whole code at once, so the moment of completion is the natural submit."
14476
+ "description": "Use it for the headline figure that a control on the same screen changes — the total above a chart, the price beside a quantity stepper — so cause and effect are seen together."
13596
14477
  },
13597
14478
  {
13598
14479
  "guidance": "do",
13599
- "description": "Label it through a `Field` (\"Verification code\") and say where the code was sent in the description. The first slot takes the field's name; the rest are announced by position."
14480
+ "description": "Format through `format` (currency, percent, compact) rather than pre-formatting a string, so separators and units stay put while only the digits roll."
13600
14481
  },
13601
14482
  {
13602
14483
  "guidance": "do",
13603
- "description": "Use `groupSize` for codes of 6 or more so the user can check the code in chunks against the message they are reading from."
14484
+ "description": "Pass `aria-live=\"polite\"` only when the change itself is news the reader would otherwise miss; most tickers sit beside the control that changed them and need no announcement."
13604
14485
  },
13605
14486
  {
13606
14487
  "guidance": "dont",
13607
- "description": "Do not use OtpField for a password, an arbitrary-length PIN, or anything the user types from memory. Slots promise a fixed length; use Input with `type=\"password\"` and `autocomplete=\"current-password\"` instead."
14488
+ "description": "Do not use it for values that change many times a second (a stopwatch, a stock tick, a progress percentage) — the rolls never land and read as a blur. Render the number as plain tabular text."
13608
14489
  },
13609
14490
  {
13610
14491
  "guidance": "dont",
13611
- "description": "Do not intercept paste or clear the field on a wrong code before the user has seen the error. Blocking paste breaks SMS autofill and password managers, and wiping the slots erases the evidence of which digit was wrong."
14492
+ "description": "Do not use it for numbers that are names rather than amounts — an order ID, a phone number, a year, a version. Rolling implies a quantity going up or down."
13612
14493
  },
13613
14494
  {
13614
14495
  "guidance": "dont",
13615
- "description": "Do not swap it for NumberField because the code is digits. A code is an identifier, not a quantity: NumberField strips leading zeros and offers steppers that make no sense here."
13616
- }
13617
- ],
13618
- "anatomy": [
13619
- {
13620
- "name": "Root",
13621
- "required": true,
13622
- "description": "A role=group that holds the value, carries the field label and description, and reports completion."
13623
- },
13624
- {
13625
- "name": "Slot",
13626
- "required": true,
13627
- "description": "One input per character. The first carries autocomplete=\"one-time-code\" and accepts the whole code."
14496
+ "description": "Do not animate a number on page load to make it feel alive. The first paint is at rest on purpose; for an entrance, reveal the card it sits in (the Reveal motion primitive)."
13628
14497
  },
13629
14498
  {
13630
- "name": "Group gap",
13631
- "required": false,
13632
- "description": "A decorative dash inserted every `groupSize` slots; hidden from assistive tech."
14499
+ "guidance": "dont",
14500
+ "description": "Do not use it for a value inside a known range that needs a picture of how full it is — show a Meter, with the number beside it.",
14501
+ "instead": [
14502
+ "Meter"
14503
+ ]
13633
14504
  }
13634
14505
  ]
13635
14506
  },
13636
14507
  "related": {
13637
14508
  "components": [
13638
- "Input",
13639
- "Form",
13640
- "NumberField"
14509
+ "Meter",
14510
+ "NotificationBadge",
14511
+ "PieChart",
14512
+ "RadialChart",
14513
+ "Sparkline"
13641
14514
  ]
13642
14515
  },
13643
14516
  "props": [
13644
14517
  {
13645
- "name": "length",
14518
+ "name": "value",
13646
14519
  "type": "number",
13647
- "default": "6",
13648
- "description": "Number of character slots. Completion fires when every slot is filled."
14520
+ "description": "The number to show. When it changes, the digits roll to it."
13649
14521
  },
13650
14522
  {
13651
- "name": "groupSize",
13652
- "type": "number",
13653
- "description": "Insert a visual gap every N slots (3 renders 123 – 456). The value stays one contiguous string."
14523
+ "name": "format",
14524
+ "type": "Intl.NumberFormatOptions",
14525
+ "description": "How to write the number — `{ style: \"currency\", currency: \"USD\" }`, `{ style: \"percent\" }`, `{ notation: \"compact\" }`, fraction digits. Passed to Intl.NumberFormat; an inline object is fine (it is compared by value)."
13654
14526
  },
13655
14527
  {
13656
- "name": "value / defaultValue",
13657
- "type": "string",
13658
- "description": "Controlled / uncontrolled code."
14528
+ "name": "locales",
14529
+ "type": "string | string[]",
14530
+ "default": "runtime locale",
14531
+ "description": "BCP 47 locale(s) for the formatting. Digits roll through the locale's own numbering system."
13659
14532
  },
13660
14533
  {
13661
- "name": "onValueChange",
13662
- "type": "(value: string, details) => void",
13663
- "description": "Fires on every edit. `details.reason` is input-change, input-clear, input-paste or keyboard."
14534
+ "name": "prefix",
14535
+ "type": "ReactNode",
14536
+ "description": "Before the number. A string becomes part of the number's text (and rolls in or out with it); an element is rendered as given."
13664
14537
  },
13665
14538
  {
13666
- "name": "onValueComplete",
13667
- "type": "(value: string, details) => void",
13668
- "description": "Fires once every slot is filled — the place to verify the code. Also fires when a complete code is pasted over a complete value."
14539
+ "name": "suffix",
14540
+ "type": "ReactNode",
14541
+ "description": "After the number (\"+\", \" pts\"). A string becomes part of the number's text; an element is rendered as given."
13669
14542
  },
13670
14543
  {
13671
- "name": "onValueInvalid",
13672
- "type": "(attempted: string, details) => void",
13673
- "description": "Fires when typed or pasted text contains characters `validationType` rejects, so you can explain the rejection rather than letting it vanish."
14544
+ "name": "trend",
14545
+ "type": "\"auto\" | \"up\" | \"down\"",
14546
+ "default": "\"auto\"",
14547
+ "description": "Which way the digits roll. `auto` rolls up as the value grows and down as it shrinks; `up` / `down` fix it (a countdown, a score that only climbs)."
13674
14548
  },
13675
14549
  {
13676
- "name": "validationType",
13677
- "type": "\"numeric\" | \"alpha\" | \"alphanumeric\" | \"none\"",
13678
- "default": "\"numeric\"",
13679
- "description": "Which characters a slot accepts. Also picks the virtual keyboard (numeric → number pad)."
14550
+ "name": "motion",
14551
+ "type": "\"full\" | \"subtle\" | \"off\"",
14552
+ "default": "\"full\"",
14553
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. The roll is expressive, so `subtle` updates the value in place."
13680
14554
  },
13681
14555
  {
13682
- "name": "autoSubmit",
13683
- "type": "boolean",
13684
- "default": "false",
13685
- "description": "Submit the owning form when the code becomes complete."
14556
+ "name": "className",
14557
+ "type": "string",
14558
+ "description": "Merged onto the root span. Set the type tier here (`text-heading-2`, `text-display-lg`); the ticker adds tabular figures so widths hold still."
13686
14559
  },
13687
14560
  {
13688
- "name": "mask",
13689
- "type": "boolean",
13690
- "default": "false",
13691
- "description": "Render slots as password inputs. Only for codes that are secret on screen, such as a card PIN."
14561
+ "name": "...span props",
14562
+ "type": "Omit<ComponentPropsWithRef<\"span\">, \"children\" | \"prefix\">",
14563
+ "description": "Remaining span attributes pass through to the root — `aria-live` to announce changes, `id`, data attributes."
14564
+ }
14565
+ ],
14566
+ "tokensUsed": [],
14567
+ "motion": [
14568
+ {
14569
+ "slot": "arrival",
14570
+ "recipe": "none",
14571
+ "part": "Root",
14572
+ "why": "The first paint is at rest. A number counting up on page load is decoration that delays the reading; the roll is kept for the moment the value actually changes."
13692
14573
  },
13693
14574
  {
13694
- "name": "getSlotLabel",
13695
- "type": "(index: number, length: number) => string",
14575
+ "slot": "response",
14576
+ "recipe": "none",
14577
+ "part": "Root",
14578
+ "why": "It is a reading, not a control; the control that changes it gives the feedback."
14579
+ },
14580
+ {
14581
+ "slot": "transition",
14582
+ "recipe": "custom",
14583
+ "part": "Digit",
14584
+ "why": "Each digit column turns to its new place on the expressive roll (the slower token, emphasized easing, staggered from the ones column), and arriving or departing columns ease their width. The roll is what shows a change happened and how big it was — the hundreds turning reads as a bigger move than the ones. No recipe can translate per-digit columns, so it is custom; it is multiplied by --motion-expressive, so `subtle` and reduced motion land the value at once."
14585
+ },
14586
+ {
14587
+ "slot": "activity",
14588
+ "recipe": "none",
14589
+ "part": "Root",
14590
+ "why": "A number has no working state; show a Skeleton while it loads."
14591
+ }
14592
+ ],
14593
+ "a11y": [
14594
+ "The whole written number (with any string prefix or suffix) is real text in a visually hidden span; the rolling columns are aria-hidden and draw their glyphs as generated content, so screen readers, find-in-page and copy all get \"$1,234.50\", never a stack of digits.",
14595
+ "It is not a live region by default — a figure that changes because the reader moved a control is already where their attention is. Pass `aria-live=\"polite\"` when a change elsewhere should be announced.",
14596
+ "Tabular figures keep every digit the same width, so a roll never shifts the text around it."
14597
+ ],
14598
+ "examples": [
14599
+ {
14600
+ "label": "Basic",
14601
+ "code": "<NumberTicker value={1284} className=\"text-heading-2\" />",
14602
+ "imports": "import { NumberTicker } from \"@sableui/react\""
14603
+ },
14604
+ {
14605
+ "label": "Currency",
14606
+ "code": "<NumberTicker value={4280.5} format={{ style: \"currency\", currency: \"USD\" }} className=\"text-display-lg\" />",
14607
+ "imports": "import { NumberTicker } from \"@sableui/react\""
14608
+ },
14609
+ {
14610
+ "label": "Percent, announced",
14611
+ "code": "<NumberTicker value={0.428} format={{ style: \"percent\", maximumFractionDigits: 1 }} aria-live=\"polite\" />",
14612
+ "imports": "import { NumberTicker } from \"@sableui/react\""
14613
+ },
14614
+ {
14615
+ "label": "Subtle",
14616
+ "code": "<NumberTicker value={92} suffix=\" pts\" motion=\"subtle\" />",
14617
+ "imports": "import { NumberTicker } from \"@sableui/react\""
14618
+ }
14619
+ ]
14620
+ },
14621
+ {
14622
+ "name": "OtpField",
14623
+ "status": "stable",
14624
+ "group": "Forms",
14625
+ "register": "either",
14626
+ "path": "components/otp-field",
14627
+ "summary": "One-time-code entry over Base UI's OTP Field — one slot per character, SMS autofill on the first slot, paste fills every slot, and only one tab stop for the whole code.",
14628
+ "keywords": [
14629
+ "otp",
14630
+ "one-time code",
14631
+ "one-time password",
14632
+ "verification code",
14633
+ "2fa",
14634
+ "two-factor",
14635
+ "mfa",
14636
+ "pin",
14637
+ "passcode",
14638
+ "input otp"
14639
+ ],
14640
+ "usage": {
14641
+ "description": "Use OtpField for a short, fixed-length code the user copies from somewhere else — an SMS or email verification code, an authenticator app, a device pairing PIN. The slots show how long the code is and where the user is in it, which a plain Input cannot.",
14642
+ "bestPractices": [
14643
+ {
14644
+ "guidance": "do",
14645
+ "description": "Set `length` to the exact length the server sends, and verify in `onValueComplete` (or with `autoSubmit` inside a form). Most users paste or autofill the whole code at once, so the moment of completion is the natural submit."
14646
+ },
14647
+ {
14648
+ "guidance": "do",
14649
+ "description": "Label it through a `Field` (\"Verification code\") and say where the code was sent in the description. The first slot takes the field's name; the rest are announced by position."
14650
+ },
14651
+ {
14652
+ "guidance": "do",
14653
+ "description": "Use `groupSize` for codes of 6 or more so the user can check the code in chunks against the message they are reading from."
14654
+ },
14655
+ {
14656
+ "guidance": "dont",
14657
+ "description": "Do not use OtpField for a password, an arbitrary-length PIN, or anything the user types from memory. Slots promise a fixed length; use Input with `type=\"password\"` and `autocomplete=\"current-password\"` instead."
14658
+ },
14659
+ {
14660
+ "guidance": "do",
14661
+ "description": "Clear the error as soon as the user edits the code (`onValueChange` → reset the error), and set it again when the next code is refused. Each refusal is then a fresh change to invalid, so the field shakes for every wrong code, not only the first."
14662
+ },
14663
+ {
14664
+ "guidance": "dont",
14665
+ "description": "Do not intercept paste or clear the field on a wrong code before the user has seen the error. Blocking paste breaks SMS autofill and password managers, and wiping the slots erases the evidence of which digit was wrong."
14666
+ },
14667
+ {
14668
+ "guidance": "dont",
14669
+ "description": "Do not swap it for NumberField because the code is digits. A code is an identifier, not a quantity: NumberField strips leading zeros and offers steppers that make no sense here."
14670
+ }
14671
+ ],
14672
+ "anatomy": [
14673
+ {
14674
+ "name": "Root",
14675
+ "required": true,
14676
+ "description": "A role=group that holds the value, carries the field label and description, and reports completion."
14677
+ },
14678
+ {
14679
+ "name": "Slot",
14680
+ "required": true,
14681
+ "description": "One input per character. The first carries autocomplete=\"one-time-code\" and accepts the whole code."
14682
+ },
14683
+ {
14684
+ "name": "Group gap",
14685
+ "required": false,
14686
+ "description": "A decorative dash inserted every `groupSize` slots; hidden from assistive tech."
14687
+ }
14688
+ ]
14689
+ },
14690
+ "related": {
14691
+ "components": [
14692
+ "Input",
14693
+ "Form",
14694
+ "NumberField"
14695
+ ]
14696
+ },
14697
+ "props": [
14698
+ {
14699
+ "name": "length",
14700
+ "type": "number",
14701
+ "default": "6",
14702
+ "description": "Number of character slots. Completion fires when every slot is filled."
14703
+ },
14704
+ {
14705
+ "name": "groupSize",
14706
+ "type": "number",
14707
+ "description": "Insert a visual gap every N slots (3 renders 123 – 456). The value stays one contiguous string."
14708
+ },
14709
+ {
14710
+ "name": "value / defaultValue",
14711
+ "type": "string",
14712
+ "description": "Controlled / uncontrolled code."
14713
+ },
14714
+ {
14715
+ "name": "onValueChange",
14716
+ "type": "(value: string, details) => void",
14717
+ "description": "Fires on every edit. `details.reason` is input-change, input-clear, input-paste or keyboard."
14718
+ },
14719
+ {
14720
+ "name": "onValueComplete",
14721
+ "type": "(value: string, details) => void",
14722
+ "description": "Fires once every slot is filled — the place to verify the code. Also fires when a complete code is pasted over a complete value."
14723
+ },
14724
+ {
14725
+ "name": "onValueInvalid",
14726
+ "type": "(attempted: string, details) => void",
14727
+ "description": "Fires when typed or pasted text contains characters `validationType` rejects, so you can explain the rejection rather than letting it vanish."
14728
+ },
14729
+ {
14730
+ "name": "validationType",
14731
+ "type": "\"numeric\" | \"alpha\" | \"alphanumeric\" | \"none\"",
14732
+ "default": "\"numeric\"",
14733
+ "description": "Which characters a slot accepts. Also picks the virtual keyboard (numeric → number pad)."
14734
+ },
14735
+ {
14736
+ "name": "autoSubmit",
14737
+ "type": "boolean",
14738
+ "default": "false",
14739
+ "description": "Submit the owning form when the code becomes complete."
14740
+ },
14741
+ {
14742
+ "name": "mask",
14743
+ "type": "boolean",
14744
+ "default": "false",
14745
+ "description": "Render slots as password inputs. Only for codes that are secret on screen, such as a card PIN."
14746
+ },
14747
+ {
14748
+ "name": "getSlotLabel",
14749
+ "type": "(index: number, length: number) => string",
13696
14750
  "default": "Character N of L",
13697
14751
  "description": "Accessible name for slots after the first. Override to localize."
13698
14752
  },
13699
14753
  {
13700
14754
  "name": "aria-invalid",
13701
14755
  "type": "boolean",
13702
- "description": "Marks every slot invalid (destructive border). `<Field error>` sets it for you."
14756
+ "description": "Marks every slot invalid (destructive border and digits). Turning it on after mount shakes the field once. `<Field error>` sets it for you."
14757
+ },
14758
+ {
14759
+ "name": "motion",
14760
+ "type": "\"full\" | \"subtle\" | \"off\"",
14761
+ "default": "\"full\"",
14762
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. The shake is the flourish; the destructive colour stays at every level."
13703
14763
  },
13704
14764
  {
13705
14765
  "name": "disabled / readOnly / required / name / id / form",
@@ -13736,6 +14796,12 @@
13736
14796
  "part": "Slot",
13737
14797
  "why": "A filled slot strengthens its border, so a pasted code visibly lands in every slot at once. Easing it reads as the code arriving rather than the field flickering."
13738
14798
  },
14799
+ {
14800
+ "slot": "transition",
14801
+ "recipe": "motion-shake",
14802
+ "part": "Root",
14803
+ "why": "A refused code shakes the field once as it turns invalid — the short horizontal no that makes a second wrong code feel noticed rather than ignored. It never carries the refusal alone: the digits and borders turn destructive and the Field says why. It plays on the change to invalid, not on mount, and is expressive, so `subtle` keeps only the colour."
14804
+ },
13739
14805
  {
13740
14806
  "slot": "activity",
13741
14807
  "recipe": "none",
@@ -13747,7 +14813,8 @@
13747
14813
  "The root is a role=group named by the field label; the first slot shares the label through `id`/`htmlFor`, and later slots are announced by position (\"Character 2 of 6\").",
13748
14814
  "Only one slot is in the tab order. Arrow keys, Home/End and Backspace move between slots; Tab leaves the whole field.",
13749
14815
  "The first slot carries autocomplete=\"one-time-code\" and the numeric virtual keyboard, so iOS and Android offer the SMS code above the keyboard.",
13750
- "Slots render at 20px (`text-mono-lg`), above the 16px floor that stops iOS Safari zooming on focus."
14816
+ "Slots render at 20px (`text-mono-lg`), above the 16px floor that stops iOS Safari zooming on focus.",
14817
+ "An invalid code is never signalled by motion alone: every slot carries aria-invalid, the digits and borders turn destructive, and the Field's error message is wired through aria-describedby. The shake resolves instantly under reduced motion and `subtle`."
13751
14818
  ],
13752
14819
  "examples": [
13753
14820
  {
@@ -13765,6 +14832,11 @@
13765
14832
  "code": "<Field label=\"Verification code\" error=\"That code has expired. Request a new code and try again.\"><OtpField length={6} groupSize={3} defaultValue=\"482913\" /></Field>",
13766
14833
  "imports": "import { Field, OtpField } from \"@sableui/react\""
13767
14834
  },
14835
+ {
14836
+ "label": "Verify on completion, shake on a wrong code",
14837
+ "code": "const [code, setCode] = useState(\"\")\nconst [wrong, setWrong] = useState(false)\n<Field label=\"Verification code\" error={wrong ? \"That code does not match. Check the message and try again.\" : undefined}><OtpField length={6} groupSize={3} value={code} onValueChange={(next) => { setCode(next); setWrong(false) }} onValueComplete={(value) => setWrong(value !== expected)} /></Field>",
14838
+ "imports": "import { Field, OtpField } from \"@sableui/react\""
14839
+ },
13768
14840
  {
13769
14841
  "label": "Alphanumeric",
13770
14842
  "code": "<Field label=\"Pairing code\"><OtpField length={8} groupSize={4} validationType=\"alphanumeric\" /></Field>",
@@ -14355,6 +15427,32 @@
14355
15427
  "hairline",
14356
15428
  "hairline-strong"
14357
15429
  ],
15430
+ "motion": [
15431
+ {
15432
+ "slot": "arrival",
15433
+ "recipe": "none",
15434
+ "part": "Root",
15435
+ "why": "Panels are the app's frame, present from first paint. A dock that slid in on load would make the workspace look like it is still assembling."
15436
+ },
15437
+ {
15438
+ "slot": "response",
15439
+ "recipe": "motion-interactive",
15440
+ "part": "PanelResizer",
15441
+ "why": "The hairline widens into a visible bar on hover and turns primary on keyboard focus, eased, so a 1px edge says it can be grabbed before the pointer commits."
15442
+ },
15443
+ {
15444
+ "slot": "transition",
15445
+ "recipe": "none",
15446
+ "part": "Panel",
15447
+ "why": "Width tracks the pointer one-to-one while dragging, and keyboard steps land at once. An eased width would trail the cursor, and resizing is a layout change: easing it would reflow the canvas beside the panel on every frame."
15448
+ },
15449
+ {
15450
+ "slot": "activity",
15451
+ "recipe": "none",
15452
+ "part": "Root",
15453
+ "why": "Layout chrome has no working state."
15454
+ }
15455
+ ],
14358
15456
  "a11y": [
14359
15457
  "PanelResizer is a role=separator with aria-orientation, aria-valuenow/min/max, and is focusable — a pointer-only resizer is a keyboard trap.",
14360
15458
  "Arrow keys resize by `step`; Home and End jump to the minimum and maximum.",
@@ -14495,6 +15593,38 @@
14495
15593
  "muted-foreground",
14496
15594
  "hairline"
14497
15595
  ],
15596
+ "motion": [
15597
+ {
15598
+ "slot": "arrival",
15599
+ "recipe": "none",
15600
+ "part": "Root",
15601
+ "why": "Sections are part of the inspector they sit in and arrive with it. Animating each in would make a panel switch look like a page load."
15602
+ },
15603
+ {
15604
+ "slot": "response",
15605
+ "recipe": "motion-interactive",
15606
+ "part": "PropertyGroup trigger",
15607
+ "why": "The fold row eases its focus ring, so arrowing down a dense inspector shows where the keyboard is without the ring flickering from row to row."
15608
+ },
15609
+ {
15610
+ "slot": "transition",
15611
+ "recipe": "motion-disclosure",
15612
+ "part": "PropertyGroup panel",
15613
+ "why": "The group opens by easing its height, so the rows below slide down to make room instead of jumping. In an inspector, the next section moving is otherwise how you lose your place."
15614
+ },
15615
+ {
15616
+ "slot": "transition",
15617
+ "recipe": "motion-icon-swap",
15618
+ "part": "PropertyGroup indicator",
15619
+ "why": "The + collapses into − as the group opens, both glyphs stacked as one mark, so the indicator reads as this row changing state rather than a different icon landing."
15620
+ },
15621
+ {
15622
+ "slot": "activity",
15623
+ "recipe": "none",
15624
+ "part": "Root",
15625
+ "why": "The inspector edits values in place; nothing in it loads."
15626
+ }
15627
+ ],
14498
15628
  "a11y": [
14499
15629
  "PropertyGroup's row is a real button with aria-expanded (Collapsible); its summary is part of the button's name, so a closed group still announces its value.",
14500
15630
  "PropertyRow renders a real <label>; pass htmlFor with the control's id so clicking the label focuses it.",
@@ -14740,11 +15870,18 @@
14740
15870
  "default": "runtime locale",
14741
15871
  "description": "BCP 47 locale for default number and date formatting."
14742
15872
  },
15873
+ {
15874
+ "name": "motion",
15875
+ "type": "\"full\" | \"subtle\" | \"off\"",
15876
+ "default": "\"full\"",
15877
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. The sweep, the slice morph and the rolling centre number are the flourish; the hover pull-out and dim are the feedback `subtle` keeps."
15878
+ },
14743
15879
  {
14744
15880
  "name": "animate",
14745
15881
  "type": "boolean",
14746
15882
  "default": "true",
14747
- "description": "Sweep the slices in clockwise when the chart scrolls into view."
15883
+ "description": "Sweep the slices in clockwise when the chart scrolls into view. `animate={false}` is `motion=\"subtle\"`.",
15884
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to: no arrival, hover kept) or motion=\"off\""
14748
15885
  },
14749
15886
  {
14750
15887
  "name": "activeIndex",
@@ -14781,19 +15918,25 @@
14781
15918
  "slot": "arrival",
14782
15919
  "recipe": "custom",
14783
15920
  "part": "Slices",
14784
- "why": "Once on screen the slices are revealed by a mask that sweeps clockwise from 12 o'clock — the whole is read as it fills. On the motion tokens; reduced motion shows the finished chart."
15921
+ "why": "Once on screen the slices are revealed by a mask that sweeps clockwise from 12 o'clock — the whole is read as it fills. On the motion tokens; reduced motion shows the finished chart. The arrival is expressive: it plays at the full motion level only, so `motion=\"subtle\"` (or a subtle app policy) shows the finished chart while hover feedback stays."
14785
15922
  },
14786
15923
  {
14787
15924
  "slot": "response",
14788
15925
  "recipe": "none",
14789
15926
  "part": "Active slice",
14790
- "why": "The hovered slice pulls outward and the rest dim at once; the geometry change itself eases (see transition)."
15927
+ "why": "The hovered slice pulls outward and the rest dim at once (mixed toward the surface, not faded); the geometry change itself eases (see transition). Essential: `subtle` keeps it."
15928
+ },
15929
+ {
15930
+ "slot": "transition",
15931
+ "recipe": "custom",
15932
+ "part": "Centre value",
15933
+ "why": "A numeric centre value rolls to its new total digit by digit (NumberTicker) when the data changes, so the headline is seen moving with the slices instead of being found different afterwards. Expressive: under `subtle` it updates in place."
14791
15934
  },
14792
15935
  {
14793
15936
  "slot": "transition",
14794
15937
  "recipe": "custom",
14795
15938
  "part": "Slices",
14796
- "why": "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place."
15939
+ "why": "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place. Expressive, like the arrival: under `subtle` new data swaps in place."
14797
15940
  },
14798
15941
  {
14799
15942
  "slot": "activity",
@@ -14943,6 +16086,38 @@
14943
16086
  "muted-foreground",
14944
16087
  "foreground"
14945
16088
  ],
16089
+ "motion": [
16090
+ {
16091
+ "slot": "arrival",
16092
+ "recipe": "none",
16093
+ "part": "Root",
16094
+ "why": "Pills arrive with the filter row or the content they annotate. A chip that animated in alone would read as a filter someone just added."
16095
+ },
16096
+ {
16097
+ "slot": "response",
16098
+ "recipe": "motion-interactive",
16099
+ "part": "Root",
16100
+ "why": "The hover wash eases, so sweeping the pointer along a filter row reads as one cursor moving rather than chips flashing in turn."
16101
+ },
16102
+ {
16103
+ "slot": "response",
16104
+ "recipe": "motion-press",
16105
+ "part": "Root",
16106
+ "why": "A pill often toggles a filter that takes a moment to reach the results; the settle on press confirms the click landed before they change."
16107
+ },
16108
+ {
16109
+ "slot": "transition",
16110
+ "recipe": "motion-interactive",
16111
+ "part": "Root",
16112
+ "why": "`selected` lifts the chip onto its selected surface on the same ease, so toggling reads as this chip changing state, not a different chip appearing in its place."
16113
+ },
16114
+ {
16115
+ "slot": "activity",
16116
+ "recipe": "none",
16117
+ "part": "Root",
16118
+ "why": "A chip is a choice, not a process. If applying it is slow, the results show it."
16119
+ }
16120
+ ],
14946
16121
  "a11y": [
14947
16122
  "Renders a native <button type=\"button\"> for focus and keyboard activation.",
14948
16123
  "Selected state is exposed via aria-pressed."
@@ -15423,11 +16598,18 @@
15423
16598
  "default": "runtime locale",
15424
16599
  "description": "BCP 47 locale for default number and date formatting."
15425
16600
  },
16601
+ {
16602
+ "name": "motion",
16603
+ "type": "\"full\" | \"subtle\" | \"off\"",
16604
+ "default": "\"full\"",
16605
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. The polygons growing in and morphing are the flourish; the hover highlight is the feedback `subtle` keeps."
16606
+ },
15426
16607
  {
15427
16608
  "name": "animate",
15428
16609
  "type": "boolean",
15429
16610
  "default": "true",
15430
- "description": "Grow the polygons from the centre when the chart scrolls into view."
16611
+ "description": "Grow the polygons from the centre when the chart scrolls into view. `animate={false}` is `motion=\"subtle\"`.",
16612
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to: no arrival, hover kept) or motion=\"off\""
15431
16613
  },
15432
16614
  {
15433
16615
  "name": "activeIndex",
@@ -15464,7 +16646,7 @@
15464
16646
  "slot": "arrival",
15465
16647
  "recipe": "custom",
15466
16648
  "part": "Polygon",
15467
- "why": "Once on screen each polygon grows from the centre and fades in, series staggered, on the emphasized easing. On the motion tokens; reduced motion shows the finished chart."
16649
+ "why": "Once on screen each polygon grows from the centre and fades in, series staggered, on the emphasized easing. On the motion tokens; reduced motion shows the finished chart. The arrival is expressive: it plays at the full motion level only, so `motion=\"subtle\"` (or a subtle app policy) shows the finished chart while hover feedback stays."
15468
16650
  },
15469
16651
  {
15470
16652
  "slot": "response",
@@ -15476,7 +16658,7 @@
15476
16658
  "slot": "transition",
15477
16659
  "recipe": "custom",
15478
16660
  "part": "Polygon",
15479
- "why": "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place."
16661
+ "why": "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place. Expressive, like the arrival: under `subtle` new data swaps in place."
15480
16662
  },
15481
16663
  {
15482
16664
  "slot": "activity",
@@ -15640,11 +16822,18 @@
15640
16822
  "default": "runtime locale",
15641
16823
  "description": "BCP 47 locale for default number and date formatting."
15642
16824
  },
16825
+ {
16826
+ "name": "motion",
16827
+ "type": "\"full\" | \"subtle\" | \"off\"",
16828
+ "default": "\"full\"",
16829
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. The draw-on, the ring morph and the rolling centre number are the flourish; the hover dim is the feedback `subtle` keeps."
16830
+ },
15643
16831
  {
15644
16832
  "name": "animate",
15645
16833
  "type": "boolean",
15646
16834
  "default": "true",
15647
- "description": "Draw the rings round from their start when the chart scrolls into view."
16835
+ "description": "Draw the rings round from their start when the chart scrolls into view. `animate={false}` is `motion=\"subtle\"`.",
16836
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to: no arrival, hover kept) or motion=\"off\""
15648
16837
  },
15649
16838
  {
15650
16839
  "name": "activeIndex",
@@ -15681,19 +16870,25 @@
15681
16870
  "slot": "arrival",
15682
16871
  "recipe": "custom",
15683
16872
  "part": "Ring",
15684
- "why": "Once on screen each ring draws round from its start (stroke-dashoffset on a round-capped arc), staggered outside in. On the motion tokens; reduced motion shows the finished chart."
16873
+ "why": "Once on screen each ring draws round from its start (stroke-dashoffset on a round-capped arc), staggered outside in. On the motion tokens; reduced motion shows the finished chart. The arrival is expressive: it plays at the full motion level only, so `motion=\"subtle\"` (or a subtle app policy) shows the finished chart while hover feedback stays."
15685
16874
  },
15686
16875
  {
15687
16876
  "slot": "response",
15688
16877
  "recipe": "none",
15689
16878
  "part": "Active ring",
15690
- "why": "The hovered ring stays full strength and the others dim at once."
16879
+ "why": "The hovered ring stays full strength and the others dim at once — mixed toward the surface rather than faded, so a dimmed ring stays solid over its track instead of turning muddy. Essential: `subtle` keeps it."
16880
+ },
16881
+ {
16882
+ "slot": "transition",
16883
+ "recipe": "custom",
16884
+ "part": "Centre value",
16885
+ "why": "A numeric centre value rolls to its new total digit by digit (NumberTicker) when the data changes, so the headline is seen moving with the rings instead of being found different afterwards. Expressive: under `subtle` it updates in place."
15691
16886
  },
15692
16887
  {
15693
16888
  "slot": "transition",
15694
16889
  "recipe": "custom",
15695
16890
  "part": "Ring",
15696
- "why": "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place."
16891
+ "why": "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place. Expressive, like the arrival: under `subtle` new data swaps in place."
15697
16892
  },
15698
16893
  {
15699
16894
  "slot": "activity",
@@ -15799,11 +16994,37 @@
15799
16994
  "primary",
15800
16995
  "ring"
15801
16996
  ],
15802
- "a11y": [
15803
- "Base UI provides the radiogroup/radio roles, aria-checked, hidden native inputs, and roving tabindex.",
15804
- "Arrow keys move and select between items; Space selects the focused item; Tab enters/leaves the group.",
15805
- "Checked state styling reads from the data-[checked] attribute; disabled items from the disabled state.",
15806
- "Focus-visible draws a 2px ring on the focused item. Wrap each Radio in <Field label orientation=\"horizontal\"> so the label names it, is part of the hit target, sits on the first line, and dims with it when disabled — do not hand-build a <label> row."
16997
+ "motion": [
16998
+ {
16999
+ "slot": "arrival",
17000
+ "recipe": "none",
17001
+ "part": "Root",
17002
+ "why": "Radios arrive with the form they belong to. Animating each one in turns a question into a title sequence."
17003
+ },
17004
+ {
17005
+ "slot": "response",
17006
+ "recipe": "motion-interactive",
17007
+ "part": "Root",
17008
+ "why": "The ring's edge and focus ring ease, so arrowing through a group — which moves the selection with focus — reads as one choice moving down the list."
17009
+ },
17010
+ {
17011
+ "slot": "transition",
17012
+ "recipe": "motion-interactive",
17013
+ "part": "Indicator",
17014
+ "why": "The dot grows from the centre on a spring and shrinks out of the radio being left. A group's selection moves from one option to another, and the paired grow and shrink is what makes it read as moving rather than two dots blinking."
17015
+ },
17016
+ {
17017
+ "slot": "activity",
17018
+ "recipe": "none",
17019
+ "part": "Root",
17020
+ "why": "A radio commits instantly. A slow write belongs to the surrounding form's pending state, not the control."
17021
+ }
17022
+ ],
17023
+ "a11y": [
17024
+ "Base UI provides the radiogroup/radio roles, aria-checked, hidden native inputs, and roving tabindex.",
17025
+ "Arrow keys move and select between items; Space selects the focused item; Tab enters/leaves the group.",
17026
+ "Checked state styling reads from the data-[checked] attribute; disabled items from the disabled state.",
17027
+ "Focus-visible draws a 2px ring on the focused item. Wrap each Radio in <Field label orientation=\"horizontal\"> so the label names it, is part of the hit target, sits on the first line, and dims with it when disabled — do not hand-build a <label> row."
15807
17028
  ],
15808
17029
  "examples": [
15809
17030
  {
@@ -15951,6 +17172,44 @@
15951
17172
  "tone-info",
15952
17173
  "tone-warning"
15953
17174
  ],
17175
+ "motion": [
17176
+ {
17177
+ "slot": "arrival",
17178
+ "recipe": "none",
17179
+ "part": "Root",
17180
+ "why": "Cards arrive with the form. A plan picker that animated each card in would delay the choice it exists to make."
17181
+ },
17182
+ {
17183
+ "slot": "response",
17184
+ "recipe": "motion-interactive",
17185
+ "part": "Root",
17186
+ "why": "The edge steps toward the ring colour on hover, eased. On a card-sized target a hard snap across the whole border reads as the layout flickering."
17187
+ },
17188
+ {
17189
+ "slot": "response",
17190
+ "recipe": "motion-press",
17191
+ "part": "Root",
17192
+ "why": "The settle on press confirms a click anywhere in the card — the radio is small, and without it a click in the card's body can feel like it missed the control."
17193
+ },
17194
+ {
17195
+ "slot": "transition",
17196
+ "recipe": "motion-interactive",
17197
+ "part": "Root",
17198
+ "why": "Checking eases the tone ring and wash onto the new card as the old one releases, so the choice reads as moving between cards rather than two cards repainting."
17199
+ },
17200
+ {
17201
+ "slot": "transition",
17202
+ "recipe": "motion-interactive",
17203
+ "part": "Indicator",
17204
+ "why": "The radio dot grows in on a spring, the same mark as Radio, so a card and a plain radio confirm a choice the same way."
17205
+ },
17206
+ {
17207
+ "slot": "activity",
17208
+ "recipe": "none",
17209
+ "part": "Root",
17210
+ "why": "A choice commits instantly. A slow write belongs to the surrounding form's pending state."
17211
+ }
17212
+ ],
15954
17213
  "a11y": [
15955
17214
  "Base UI provides the radiogroup/radio roles, aria-checked, hidden native inputs, and roving tabindex.",
15956
17215
  "Arrow keys move and select between cards; Space selects the focused card; Tab enters/leaves the group.",
@@ -16039,6 +17298,38 @@
16039
17298
  "border",
16040
17299
  "ring"
16041
17300
  ],
17301
+ "motion": [
17302
+ {
17303
+ "slot": "arrival",
17304
+ "recipe": "none",
17305
+ "part": "Root",
17306
+ "why": "It is a word in a line of text and arrives with that line."
17307
+ },
17308
+ {
17309
+ "slot": "response",
17310
+ "recipe": "none",
17311
+ "part": "Root",
17312
+ "why": "The only hover change on the timestamp is a dotted underline, and text-decoration-line is discrete: CSS cannot tween it, so a transition here would animate nothing and imply the snap had been handled."
17313
+ },
17314
+ {
17315
+ "slot": "response",
17316
+ "recipe": "motion-pop",
17317
+ "part": "Tooltip",
17318
+ "why": "The pointer's real answer is the absolute time, which arrives in a Tooltip on its own fast pop. That short entrance is what makes the full date read as belonging to the word under the cursor."
17319
+ },
17320
+ {
17321
+ "slot": "transition",
17322
+ "recipe": "none",
17323
+ "part": "Root",
17324
+ "why": "The label does not tick: it is computed when the component renders, so it never changes in front of the reader. A timestamp rolling from one minute to two would pull the eye off the content it dates."
17325
+ },
17326
+ {
17327
+ "slot": "activity",
17328
+ "recipe": "none",
17329
+ "part": "Root",
17330
+ "why": "A timestamp has nothing to wait for."
17331
+ }
17332
+ ],
16042
17333
  "a11y": [
16043
17334
  "Renders a semantic <time> element with a machine-readable dateTime (ISO) attribute.",
16044
17335
  "The <time> carries tabIndex=0 (it is not focusable natively), so the absolute time — exposed via Base UI's Tooltip, which opens on keyboard focus as well as hover and wires aria-describedby — is reachable from the keyboard.",
@@ -16163,6 +17454,38 @@
16163
17454
  "tokensUsed": [
16164
17455
  "ring"
16165
17456
  ],
17457
+ "motion": [
17458
+ {
17459
+ "slot": "arrival",
17460
+ "recipe": "none",
17461
+ "part": "Root",
17462
+ "why": "It is a region, not an event; its content arrives with whatever fills it."
17463
+ },
17464
+ {
17465
+ "slot": "response",
17466
+ "recipe": "motion-interactive",
17467
+ "part": "Viewport",
17468
+ "why": "The region is focusable so the keyboard can scroll it, and its focus ring eases in like any control's. It is the one sign the region, not the page, now takes the arrow keys."
17469
+ },
17470
+ {
17471
+ "slot": "response",
17472
+ "recipe": "custom",
17473
+ "part": "Scrollbar",
17474
+ "why": "The vertical bar (`scrollbar-quiet`) stays hidden until the region is hovered, focused or scrolled, then fades its thumb in on the motion tokens. A bar that appeared in one frame would flash every time the pointer crossed the list."
17475
+ },
17476
+ {
17477
+ "slot": "transition",
17478
+ "recipe": "none",
17479
+ "part": "Edge fade",
17480
+ "why": "The edge fades switch on and off as you scroll, in the frame the content is already moving, so the change is carried by the scroll itself. They are a mask gradient, which CSS cannot interpolate without registered custom properties."
17481
+ },
17482
+ {
17483
+ "slot": "activity",
17484
+ "recipe": "none",
17485
+ "part": "Root",
17486
+ "why": "A scroll region does no work of its own. Content still loading inside it brings its own Skeleton or Spinner."
17487
+ }
17488
+ ],
16166
17489
  "a11y": [
16167
17490
  "The edge fades are a CSS mask-image on the scrolling element, not overlay elements — there is nothing in the DOM to intercept a pointer or be read by assistive tech, and the content under a fade stays fully interactive.",
16168
17491
  "Two boxes: a chrome box carrying `className` (border, background, radius, sizing) and, inside it, the element that actually scrolls and carries the fade mask. They are split because a `mask-image` applies to its element's own border — so with both on one box, a bordered Scroller watched its frame dissolve at the scrolling edge. The axis is published as `data-orientation`, not `aria-orientation`, which is invalid on `role=\"group\"`.",
@@ -16330,6 +17653,38 @@
16330
17653
  "panel-field",
16331
17654
  "panel-field-hover"
16332
17655
  ],
17656
+ "motion": [
17657
+ {
17658
+ "slot": "arrival",
17659
+ "recipe": "none",
17660
+ "part": "Root",
17661
+ "why": "It arrives with the inspector row it sits in."
17662
+ },
17663
+ {
17664
+ "slot": "response",
17665
+ "recipe": "motion-interactive",
17666
+ "part": "Root",
17667
+ "why": "The shell eases its focus ring like every field, so tabbing through a column of numbers shows where the keyboard is without flicker."
17668
+ },
17669
+ {
17670
+ "slot": "response",
17671
+ "recipe": "motion-interactive",
17672
+ "part": "Handle",
17673
+ "why": "The handle's ink brightens while the field is hovered. It is the only sign that the icon or prefix can be dragged, which nobody would guess from a label."
17674
+ },
17675
+ {
17676
+ "slot": "transition",
17677
+ "recipe": "none",
17678
+ "part": "Value",
17679
+ "why": "The number follows the drag pixel for pixel. Easing it — or rolling it with a NumberTicker — would show a value the pointer has already left, in a control whose whole point is precision."
17680
+ },
17681
+ {
17682
+ "slot": "activity",
17683
+ "recipe": "none",
17684
+ "part": "Root",
17685
+ "why": "Scrubbing is synchronous; there is nothing to wait for."
17686
+ }
17687
+ ],
16333
17688
  "a11y": [
16334
17689
  "The input carries the accessible name via `label`; the glyph is aria-hidden because it is decorative.",
16335
17690
  "Every scrub gesture is reachable from the keyboard: ArrowUp/ArrowDown step by `step`, Shift+Arrow by ten steps.",
@@ -16460,6 +17815,32 @@
16460
17815
  "ring",
16461
17816
  "destructive"
16462
17817
  ],
17818
+ "motion": [
17819
+ {
17820
+ "slot": "arrival",
17821
+ "recipe": "motion-appear",
17822
+ "part": "Clear button",
17823
+ "why": "The X mounts as the first character lands. Fading and settling in from a smaller scale keeps it from popping into the corner of the eye on every new search; removal is instant so an emptied field does not wait on it."
17824
+ },
17825
+ {
17826
+ "slot": "response",
17827
+ "recipe": "motion-interactive",
17828
+ "part": "Input",
17829
+ "why": "The focus ring and invalid border ease in, so focus reads as the field answering rather than a repaint."
17830
+ },
17831
+ {
17832
+ "slot": "transition",
17833
+ "recipe": "none",
17834
+ "part": "Root",
17835
+ "why": "The field has no states of its own to move between — the clear button arriving is its only change, and arrival covers it."
17836
+ },
17837
+ {
17838
+ "slot": "activity",
17839
+ "recipe": "none",
17840
+ "part": "Root",
17841
+ "why": "Searching is the results' job: a list skeleton or a Spinner beside them, not a field that animates while you type."
17842
+ }
17843
+ ],
16463
17844
  "a11y": [
16464
17845
  "Renders a native <input type=\"search\"> — keyboard and form semantics come for free.",
16465
17846
  "Associate a <label htmlFor> (or wrap) — the magnifier glyph is decorative, not a label.",
@@ -17619,6 +19000,44 @@
17619
19000
  "foreground",
17620
19001
  "ring"
17621
19002
  ],
19003
+ "motion": [
19004
+ {
19005
+ "slot": "arrival",
19006
+ "recipe": "none",
19007
+ "part": "Root",
19008
+ "why": "The preview arrives with the content it clips. Its fade and toggle appear only once a measurement proves the content overflows."
19009
+ },
19010
+ {
19011
+ "slot": "response",
19012
+ "recipe": "motion-interactive",
19013
+ "part": "Toggle",
19014
+ "why": "The toggle is a link Button, so its hover and focus ease on Button's recipe."
19015
+ },
19016
+ {
19017
+ "slot": "transition",
19018
+ "recipe": "custom",
19019
+ "part": "Content",
19020
+ "why": "The region eases its max-height between the collapsed and the measured full height on the slow motion token. motion-disclosure keys on Base UI's starting and ending styles, which this self-contained toggle does not have. The text unrolling in place keeps the reader's line; a snap drops them at a different scroll position."
19021
+ },
19022
+ {
19023
+ "slot": "transition",
19024
+ "recipe": "motion-interactive",
19025
+ "part": "Fade",
19026
+ "why": "The bottom fade dissolves as the region opens and returns as it closes, so the cut edge and the reveal read as one movement."
19027
+ },
19028
+ {
19029
+ "slot": "transition",
19030
+ "recipe": "motion-interactive",
19031
+ "part": "Chevron",
19032
+ "why": "The chevron turns 180° in step with the region (motion-interactive lists `rotate`), so the toggle visibly points at what it will do next."
19033
+ },
19034
+ {
19035
+ "slot": "activity",
19036
+ "recipe": "none",
19037
+ "part": "Root",
19038
+ "why": "Expanding is local and instant; nothing loads."
19039
+ }
19040
+ ],
17622
19041
  "a11y": [
17623
19042
  "The toggle is a real <button> with aria-expanded reflecting the open state.",
17624
19043
  "The fade overlay is pointer-events-none and decorative; it never traps clicks.",
@@ -17718,6 +19137,12 @@
17718
19137
  "type": "\"rail\" | \"page\"",
17719
19138
  "default": "\"rail\"",
17720
19139
  "description": "Which surface the rail is painted on. \"rail\" is its own --sidebar-* surface with a border — right for a full-height rail that frames the app, which a theme may tint or paint dark. \"page\" uses the page's surface and ink with no border — use it when a full-width header sits above the rail, where a tinted column starting under a page-coloured bar reads as pasted on. Use it instead of overriding bg-/text- classes, which the rail's re-pointed tokens defeat."
19140
+ },
19141
+ {
19142
+ "name": "motion",
19143
+ "type": "\"full\" | \"subtle\" | \"off\"",
19144
+ "default": "\"full\"",
19145
+ "description": "How much motion this component uses: `full` inherits the app's policy, `subtle` keeps feedback and drops the collapse ease and the current-page glide, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting."
17721
19146
  }
17722
19147
  ],
17723
19148
  "subcomponents": [
@@ -17751,6 +19176,38 @@
17751
19176
  "sidebar-accent-foreground",
17752
19177
  "sidebar-ring"
17753
19178
  ],
19179
+ "motion": [
19180
+ {
19181
+ "slot": "arrival",
19182
+ "recipe": "none",
19183
+ "part": "Root",
19184
+ "why": "The rail is the app's frame, present from first paint. It is not summoned, so it has no entrance."
19185
+ },
19186
+ {
19187
+ "slot": "response",
19188
+ "recipe": "motion-interactive",
19189
+ "part": "SidebarItem",
19190
+ "why": "Rows ease their hover fill (SideNav's row recipe on the rail rung), so moving down the nav reads as the pointer travelling rather than rows flashing."
19191
+ },
19192
+ {
19193
+ "slot": "transition",
19194
+ "recipe": "motion-glide",
19195
+ "part": "SidebarNav",
19196
+ "why": "The current-page fill is one element that travels to the new item when the page changes inside a persistent layout, so the marker reads as moving rather than one row dimming while another lights. It lands in place on first paint, and each item keeps its own fill as the server-render fallback. Expressive: `subtle` moves it at once."
19197
+ },
19198
+ {
19199
+ "slot": "transition",
19200
+ "recipe": "motion-size",
19201
+ "part": "Root",
19202
+ "why": "Collapsing to the icon rail eases the width instead of jumping 192px in one frame, so the content beside it visibly takes the room the rail gave up. Nothing inside jumps — a collapsed row's centred icon and an expanded row's inset icon sit on the same column — and labels leave at once and fade back in on reopen, clipped to the rail meanwhile. It reflows the page for the length of the ease, which is acceptable for a deliberate user toggle and never happens on load. Expressive: `subtle` snaps."
19203
+ },
19204
+ {
19205
+ "slot": "activity",
19206
+ "recipe": "none",
19207
+ "part": "Root",
19208
+ "why": "Navigation chrome has no working state; a loading route shows it in the page."
19209
+ }
19210
+ ],
17754
19211
  "a11y": [
17755
19212
  "SidebarNav renders a <nav> landmark.",
17756
19213
  "Active item carries aria-current=\"page\".",
@@ -17870,6 +19327,12 @@
17870
19327
  "type": "\"content\" | \"sidebar\"",
17871
19328
  "default": "\"content\"",
17872
19329
  "description": "Active-state token vocabulary shared by all items. \"content\" uses bg-accent/text-accent-foreground for local rails inside the content area; \"sidebar\" uses bg-sidebar-accent/text-sidebar-accent-foreground for SideNavs rendered inside app chrome."
19330
+ },
19331
+ {
19332
+ "name": "motion",
19333
+ "type": "\"full\" | \"subtle\" | \"off\"",
19334
+ "default": "\"full\"",
19335
+ "description": "How much motion this component uses: `full` inherits the app's policy, `subtle` keeps feedback and drops the current-page glide, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting."
17873
19336
  }
17874
19337
  ],
17875
19338
  "subcomponents": [
@@ -17896,6 +19359,32 @@
17896
19359
  "sidebar-accent-foreground",
17897
19360
  "sidebar-ring"
17898
19361
  ],
19362
+ "motion": [
19363
+ {
19364
+ "slot": "arrival",
19365
+ "recipe": "none",
19366
+ "part": "Root",
19367
+ "why": "In-page navigation arrives with the page it navigates."
19368
+ },
19369
+ {
19370
+ "slot": "response",
19371
+ "recipe": "motion-interactive",
19372
+ "part": "SideNavItem",
19373
+ "why": "Rows ease their hover fill and focus ring, so moving down a long nav reads as one pointer travelling rather than rows flashing in turn."
19374
+ },
19375
+ {
19376
+ "slot": "transition",
19377
+ "recipe": "motion-glide",
19378
+ "part": "SideNav",
19379
+ "why": "The current-item fill is one element that travels to the new row when the section changes inside a persistent layout, so the marker reads as moving rather than one row dimming while another lights. It lands in place on first paint, each row keeps its own fill as the server-render fallback, and the active row's heavier weight lands at once — font weight does not ease. Expressive: `subtle` moves it at once."
19380
+ },
19381
+ {
19382
+ "slot": "activity",
19383
+ "recipe": "none",
19384
+ "part": "Root",
19385
+ "why": "Navigation chrome has no working state; a loading section shows it in the content."
19386
+ }
19387
+ ],
17899
19388
  "a11y": [
17900
19389
  "SideNav renders a <nav> landmark; pair multiple navs with aria-label to distinguish them.",
17901
19390
  "The active item carries aria-current=\"page\".",
@@ -18208,6 +19697,32 @@
18208
19697
  "background",
18209
19698
  "ring"
18210
19699
  ],
19700
+ "motion": [
19701
+ {
19702
+ "slot": "arrival",
19703
+ "recipe": "none",
19704
+ "part": "Root",
19705
+ "why": "It arrives with the form or panel it sits in."
19706
+ },
19707
+ {
19708
+ "slot": "response",
19709
+ "recipe": "motion-interactive",
19710
+ "part": "Thumb",
19711
+ "why": "The thumb's dot grows from 12px to full size on hover and keyboard focus, and a little past it while dragging, on a spring. Only the paint resizes, never the hit target, so the growth says the thumb is live without moving where you have to aim."
19712
+ },
19713
+ {
19714
+ "slot": "transition",
19715
+ "recipe": "none",
19716
+ "part": "Thumb",
19717
+ "why": "The thumb and the filled range follow the pointer one-to-one, and keyboard steps land at once. An eased position would trail the drag and show a value the user has already moved past."
19718
+ },
19719
+ {
19720
+ "slot": "activity",
19721
+ "recipe": "none",
19722
+ "part": "Root",
19723
+ "why": "A slider commits as it moves; there is nothing to wait for."
19724
+ }
19725
+ ],
18211
19726
  "a11y": [
18212
19727
  "Base UI provides role=slider, aria-valuemin/max/now, and arrow-key / Page Up-Down / Home-End control.",
18213
19728
  "Each thumb is a focusable input[type=range] nested in the thumb element; keyboard focus raises a 2px ring on the thumb via has-[:focus-visible] (a bare focus-visible: on the thumb never fires, since the div itself is not focusable).",
@@ -18324,11 +19839,18 @@
18324
19839
  "default": "\"h-8 w-24\"",
18325
19840
  "description": "Sizes it; the drawing stretches to fit."
18326
19841
  },
19842
+ {
19843
+ "name": "motion",
19844
+ "type": "\"full\" | \"subtle\" | \"off\"",
19845
+ "default": "\"full\"",
19846
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. Drawing on (or rising) is the flourish, so `subtle` and `off` show the finished line."
19847
+ },
18327
19848
  {
18328
19849
  "name": "animate",
18329
19850
  "type": "boolean",
18330
19851
  "default": "true",
18331
- "description": "Draw on (or rise) when it scrolls into view."
19852
+ "description": "Draw on (or rise) when it scrolls into view. `animate={false}` is `motion=\"subtle\"`.",
19853
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to) or motion=\"off\""
18332
19854
  },
18333
19855
  {
18334
19856
  "name": "locale",
@@ -18360,7 +19882,7 @@
18360
19882
  "slot": "arrival",
18361
19883
  "recipe": "custom",
18362
19884
  "part": "Line / bars",
18363
- "why": "Once on screen the line draws on (or the bars rise, in sequence). On the motion tokens; reduced motion shows the finished drawing."
19885
+ "why": "Once on screen the line draws on (or the bars rise, in sequence). On the motion tokens; reduced motion shows the finished drawing. The arrival is expressive: it plays at the full motion level only, so `motion=\"subtle\"` (or a subtle app policy) shows the finished line."
18364
19886
  },
18365
19887
  {
18366
19888
  "slot": "response",
@@ -19425,10 +20947,36 @@
19425
20947
  "muted",
19426
20948
  "muted-foreground"
19427
20949
  ],
19428
- "a11y": [
19429
- "Renders semantic native table elements (thead/tbody/tfoot/th/td/caption) preserving built-in table semantics.",
19430
- "Use TableHead (th) for column headers and TableCaption for an accessible description.",
19431
- "Mark the active row with the data-selected attribute for a visible selected state."
20950
+ "motion": [
20951
+ {
20952
+ "slot": "arrival",
20953
+ "recipe": "none",
20954
+ "part": "Root",
20955
+ "why": "A table renders with the page it sits in. Staggering rows in delays reading data someone opened to scan."
20956
+ },
20957
+ {
20958
+ "slot": "response",
20959
+ "recipe": "motion-interactive",
20960
+ "part": "TableRow",
20961
+ "why": "Body rows ease to a muted wash under the pointer. Across a wide row, the wash is what keeps the eye on the right line, and eased it follows the cursor down the table rather than strobing."
20962
+ },
20963
+ {
20964
+ "slot": "transition",
20965
+ "recipe": "motion-interactive",
20966
+ "part": "TableCell",
20967
+ "why": "A `data-selected` row's fill eases in on every cell together, so the selected row reads as one band being set rather than a stamp."
20968
+ },
20969
+ {
20970
+ "slot": "activity",
20971
+ "recipe": "none",
20972
+ "part": "Root",
20973
+ "why": "Table is markup. Loading rows are Skeletons, which carry the shimmer."
20974
+ }
20975
+ ],
20976
+ "a11y": [
20977
+ "Renders semantic native table elements (thead/tbody/tfoot/th/td/caption) preserving built-in table semantics.",
20978
+ "Use TableHead (th) for column headers and TableCaption for an accessible description.",
20979
+ "Mark the active row with the data-selected attribute for a visible selected state."
19432
20980
  ],
19433
20981
  "examples": [
19434
20982
  {
@@ -19459,7 +21007,7 @@
19459
21007
  "group": "Navigation",
19460
21008
  "register": "either",
19461
21009
  "path": "components/tabs",
19462
- "summary": "Tabs over Base UI Tabs (roving focus, arrow-key nav, ARIA tablist wiring) in two styles: underline (page sections) or pill (compact switchers). Tabs (Root) + TabsList + TabsTrigger + TabsPanel.",
21010
+ "summary": "Tabs over Base UI Tabs (roving focus, arrow-key nav, ARIA tablist wiring) in two styles: underline (page sections) or pill (compact switchers), with one indicator that glides to the active tab. Tabs (Root) + TabsList + TabsTrigger + TabsPanel.",
19463
21011
  "keywords": [
19464
21012
  "tabs",
19465
21013
  "tablist",
@@ -19533,13 +21081,19 @@
19533
21081
  "type": "Base UI Tabs.Root props (value, defaultValue, onValueChange, orientation)",
19534
21082
  "default": "defaultValue: 0",
19535
21083
  "description": "Controls the active tab. Uncontrolled via defaultValue; controlled via value + onValueChange."
21084
+ },
21085
+ {
21086
+ "name": "motion",
21087
+ "type": "\"full\" | \"subtle\" | \"off\"",
21088
+ "default": "\"full\"",
21089
+ "description": "On Tabs: how much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the glide, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting."
19536
21090
  }
19537
21091
  ],
19538
21092
  "subcomponents": [
19539
21093
  {
19540
21094
  "name": "TabsList",
19541
21095
  "type": "Base UI Tabs.List props (activateOnFocus, loopFocus) + variant?: \"underline\" | \"pill\" + className",
19542
- "description": "The tablist row. variant=\"underline\" (default) draws a divider rule beneath the triggers; variant=\"pill\" drops the rule and fills the active trigger, which takes the theme's button shape (--button-radius). Triggers inherit the variant."
21096
+ "description": "The tablist row. variant=\"underline\" (default) draws a divider rule beneath the triggers; variant=\"pill\" drops the rule and fills the active trigger, which takes the theme's button shape (--button-radius). Triggers inherit the variant. It renders the gliding indicator (Base UI Tabs.Indicator) itself, so do not add one."
19543
21097
  },
19544
21098
  {
19545
21099
  "name": "TabsTrigger",
@@ -19564,11 +21118,44 @@
19564
21118
  "foreground",
19565
21119
  "ring"
19566
21120
  ],
21121
+ "motion": [
21122
+ {
21123
+ "slot": "arrival",
21124
+ "recipe": "none",
21125
+ "part": "Root",
21126
+ "why": "Tabs arrive with the section they organise. An entrance of their own would announce navigation chrome louder than the content it navigates."
21127
+ },
21128
+ {
21129
+ "slot": "response",
21130
+ "recipe": "motion-interactive",
21131
+ "part": "TabsTrigger",
21132
+ "why": "The label steps from muted to foreground under the pointer; the ease is what makes that read as an answer rather than a repaint."
21133
+ },
21134
+ {
21135
+ "slot": "transition",
21136
+ "recipe": "motion-glide",
21137
+ "part": "Indicator",
21138
+ "why": "One underline (or pill) travels to the new tab and stretches to its width. A mark that teleports makes the reader re-find the selection on every click; one that moves is tracked without looking. Expressive: motion=\"subtle\" lands it in place. Each tab's own mark is the fallback until the indicator has measured, so the server render is already right."
21139
+ },
21140
+ {
21141
+ "slot": "transition",
21142
+ "recipe": "animate-fade-in",
21143
+ "part": "TabsPanel",
21144
+ "why": "The incoming panel fades in, opacity only, so a switch between two similar panels still registers. Skipped on first render (activation direction \"none\") so a page of tabs does not fade in on load, and the outgoing panel leaves layout at once so a quick second click never stacks both."
21145
+ },
21146
+ {
21147
+ "slot": "activity",
21148
+ "recipe": "none",
21149
+ "part": "Root",
21150
+ "why": "Switching tabs is synchronous. A panel that loads data shows its own Skeleton or Spinner inside the panel, where the wait is."
21151
+ }
21152
+ ],
19567
21153
  "a11y": [
19568
21154
  "Base UI provides the tablist/tab/tabpanel roles, aria-selected, and roving tabindex.",
19569
21155
  "Arrow keys move between tabs; Home/End jump to first/last; panels are linked via aria-controls.",
19570
21156
  "Active tab styling reads from the data-active attribute; disabled tabs from data-disabled. Disabled tabs are skipped by arrow-key navigation.",
19571
- "Keyboard focus shows a ring on the focused trigger in both variants; the panel remains focusable."
21157
+ "Keyboard focus shows a ring on the focused trigger in both variants; the panel remains focusable.",
21158
+ "The gliding indicator is role=\"presentation\" decoration; aria-selected on the tab carries the state."
19572
21159
  ],
19573
21160
  "examples": [
19574
21161
  {
@@ -19585,6 +21172,11 @@
19585
21172
  "label": "With a disabled tab",
19586
21173
  "code": "<Tabs defaultValue=\"overview\"><TabsList><TabsTrigger value=\"overview\">Overview</TabsTrigger><TabsTrigger value=\"billing\" disabled>Billing</TabsTrigger></TabsList><TabsPanel value=\"overview\">Overview content</TabsPanel></Tabs>",
19587
21174
  "imports": "import { Tabs, TabsList, TabsPanel, TabsTrigger } from \"@sableui/react\""
21175
+ },
21176
+ {
21177
+ "label": "Subtle motion",
21178
+ "code": "<Tabs defaultValue=\"table\" motion=\"subtle\"><TabsList><TabsTrigger value=\"table\">Table</TabsTrigger><TabsTrigger value=\"chart\">Chart</TabsTrigger></TabsList><TabsPanel value=\"table\">Rows</TabsPanel><TabsPanel value=\"chart\">Trend</TabsPanel></Tabs>",
21179
+ "imports": "import { Tabs, TabsList, TabsPanel, TabsTrigger } from \"@sableui/react\""
19588
21180
  }
19589
21181
  ]
19590
21182
  },
@@ -19835,6 +21427,38 @@
19835
21427
  "muted-foreground",
19836
21428
  "ring"
19837
21429
  ],
21430
+ "motion": [
21431
+ {
21432
+ "slot": "arrival",
21433
+ "recipe": "none",
21434
+ "part": "Root",
21435
+ "why": "It sits in a settings panel or a popover, which owns the entrance."
21436
+ },
21437
+ {
21438
+ "slot": "response",
21439
+ "recipe": "motion-interactive",
21440
+ "part": "Option (list)",
21441
+ "why": "List rows ease their hover fill like Menu and Select rows, so a theme list beside a menu reads as the same kind of row."
21442
+ },
21443
+ {
21444
+ "slot": "response",
21445
+ "recipe": "motion-interactive",
21446
+ "part": "Name (grid)",
21447
+ "why": "In the grid, the theme's name brightens on hover, eased. The preview tile's outline steps rather than easing — outline is not in the recipe's transition list — so the name carries the motion."
21448
+ },
21449
+ {
21450
+ "slot": "transition",
21451
+ "recipe": "motion-interactive",
21452
+ "part": "Check (list)",
21453
+ "why": "The check fades into the chosen row and out of the old one, so the choice reads as moving, at the same moment the whole interface repaints in the new theme."
21454
+ },
21455
+ {
21456
+ "slot": "activity",
21457
+ "recipe": "none",
21458
+ "part": "Root",
21459
+ "why": "Applying a theme is synchronous: the whole interface repaints at once, and that repaint is the feedback."
21460
+ }
21461
+ ],
19838
21462
  "a11y": [
19839
21463
  "The container is role=\"radiogroup\" with an aria-label; each card is a role=\"radio\" button with aria-checked reflecting the selected theme.",
19840
21464
  "Standard radio keyboard: arrow keys move the check — calling onValueChange on each press — and Space checks the focused option. That instant switching is right for a free, reversible choice; where a switch has a cost, use a DisclosurePicker list (a listbox: arrows move, Enter commits) with ThemePreview tiles instead.",
@@ -19932,6 +21556,12 @@
19932
21556
  "default": "\"Theme\"",
19933
21557
  "description": "Accessible label for the radiogroup."
19934
21558
  },
21559
+ {
21560
+ "name": "motion",
21561
+ "type": "\"full\" | \"subtle\" | \"off\"",
21562
+ "default": "\"full\"",
21563
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the glide, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting."
21564
+ },
19935
21565
  {
19936
21566
  "name": "className",
19937
21567
  "type": "string",
@@ -19947,6 +21577,38 @@
19947
21577
  "accent-foreground",
19948
21578
  "ring"
19949
21579
  ],
21580
+ "motion": [
21581
+ {
21582
+ "slot": "arrival",
21583
+ "recipe": "none",
21584
+ "part": "Root",
21585
+ "why": "It arrives with the header or settings row it lives in; a theme control that announces itself competes with the page it themes."
21586
+ },
21587
+ {
21588
+ "slot": "response",
21589
+ "recipe": "motion-interactive",
21590
+ "part": "Option",
21591
+ "why": "The icon steps from muted to foreground under the pointer, which is what tells an icon-only segment it is a control."
21592
+ },
21593
+ {
21594
+ "slot": "response",
21595
+ "recipe": "motion-press",
21596
+ "part": "Option",
21597
+ "why": "A settle under the press confirms the tap on a 28px target before the theme change repaints the page around it."
21598
+ },
21599
+ {
21600
+ "slot": "transition",
21601
+ "recipe": "motion-glide",
21602
+ "part": "Active pill",
21603
+ "why": "One pill slides to the chosen mode, stepping by option + gap on the spacing scale, so light → dark reads as the same control moving, not three icons trading a fill. Expressive: motion=\"subtle\" lands it in place."
21604
+ },
21605
+ {
21606
+ "slot": "activity",
21607
+ "recipe": "none",
21608
+ "part": "Root",
21609
+ "why": "Changing the theme is synchronous; the repaint is the feedback."
21610
+ }
21611
+ ],
19950
21612
  "a11y": [
19951
21613
  "The container is role=\"radiogroup\" with an aria-label; each option is role=\"radio\" with aria-checked reflecting the selected value.",
19952
21614
  "Each icon button carries an aria-label (Light / Dark / System) plus a title tooltip, since the visual is icon-only.",
@@ -19968,6 +21630,11 @@
19968
21630
  "label": "Light selected",
19969
21631
  "code": "<ThemeSwitcher value=\"light\" onValueChange={setTheme} />",
19970
21632
  "imports": "import { ThemeSwitcher } from \"@sableui/react\""
21633
+ },
21634
+ {
21635
+ "label": "Subtle motion",
21636
+ "code": "<ThemeSwitcher value={theme} onValueChange={setTheme} motion=\"subtle\" />",
21637
+ "imports": "import { ThemeSwitcher } from \"@sableui/react\""
19971
21638
  }
19972
21639
  ]
19973
21640
  },
@@ -20489,7 +22156,7 @@
20489
22156
  "name": "appearance",
20490
22157
  "type": "\"default\" | \"segmented\"",
20491
22158
  "default": "\"default\"",
20492
- "description": "On ToggleGroup: `default` is a loose row of toggles; `segmented` draws the capsule segmented control (one track, the pressed option filled) shared with ThemeSwitcher. Toggles inside inherit it."
22159
+ "description": "On ToggleGroup: `default` is a loose row of toggles; `segmented` draws the capsule segmented control (one track, the pressed option filled) shared with ThemeSwitcher. Single-select segmented draws ONE fill that glides to the pressed option and resizes to its label; multi-select keeps a fill per pressed option. Toggles inside inherit it."
20493
22160
  },
20494
22161
  {
20495
22162
  "name": "multiple",
@@ -20497,6 +22164,12 @@
20497
22164
  "default": "false",
20498
22165
  "description": "On ToggleGroup: allow several toggles pressed at once. The default is single-select, which behaves like a radio group."
20499
22166
  },
22167
+ {
22168
+ "name": "motion",
22169
+ "type": "\"full\" | \"subtle\" | \"off\"",
22170
+ "default": "\"full\"",
22171
+ "description": "On ToggleGroup: how much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the glide, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting."
22172
+ },
20500
22173
  {
20501
22174
  "name": "Toggle",
20502
22175
  "type": "Base UI Toggle props (pressed, defaultPressed, onPressedChange, disabled, value) + className",
@@ -20507,7 +22180,7 @@
20507
22180
  "subcomponents": [
20508
22181
  {
20509
22182
  "name": "ToggleGroup",
20510
- "type": "Base UI ToggleGroup props (value, defaultValue, onValueChange, multiple, disabled, orientation, loopFocus) + appearance?: \"default\" | \"segmented\" + className",
22183
+ "type": "Base UI ToggleGroup props (value, defaultValue, onValueChange, multiple, disabled, orientation, loopFocus) + appearance?: \"default\" | \"segmented\" + motion?: \"full\" | \"subtle\" | \"off\" + className",
20511
22184
  "description": "Groups child Toggles and shares pressed state across them — each Toggle inside carries a value. Defaults to multiple: false (single-select) — the fit for view-mode switches and segmented controls; pass multiple to allow several pressed at once, e.g. a row of formatting toggles."
20512
22185
  },
20513
22186
  {
@@ -20531,6 +22204,38 @@
20531
22204
  "border",
20532
22205
  "ring"
20533
22206
  ],
22207
+ "motion": [
22208
+ {
22209
+ "slot": "arrival",
22210
+ "recipe": "none",
22211
+ "part": "Toggle",
22212
+ "why": "Toggles arrive with the toolbar or header they sit in. Animating each one in would make a row of controls ripple for no reason."
22213
+ },
22214
+ {
22215
+ "slot": "response",
22216
+ "recipe": "motion-interactive",
22217
+ "part": "Toggle",
22218
+ "why": "Hover and the pressed fill ease rather than flip, so pressing reads as the same button changing state, not a different button appearing."
22219
+ },
22220
+ {
22221
+ "slot": "response",
22222
+ "recipe": "motion-press",
22223
+ "part": "Toggle",
22224
+ "why": "The small settle under the finger confirms the press landed before the state change is read — the difference matters most on touch, where there is no hover."
22225
+ },
22226
+ {
22227
+ "slot": "transition",
22228
+ "recipe": "motion-glide",
22229
+ "part": "Segmented fill",
22230
+ "why": "In a single-select segmented group one fill travels to the chosen option and stretches to its label, so a view switch reads as one control now pointing elsewhere. Measured, because labels differ in width. Expressive: motion=\"subtle\" lands it in place."
22231
+ },
22232
+ {
22233
+ "slot": "activity",
22234
+ "recipe": "none",
22235
+ "part": "Toggle",
22236
+ "why": "A toggle commits instantly. Work that follows from the new state shows where its result appears, not on the control."
22237
+ }
22238
+ ],
20534
22239
  "a11y": [
20535
22240
  "Base UI sets aria-pressed on each Toggle and manages the pressed/disabled states.",
20536
22241
  "ToggleGroup provides arrow-key roving focus across its items; loopFocus wraps at the ends.",
@@ -20553,6 +22258,11 @@
20553
22258
  "code": "<ToggleGroup appearance=\"segmented\" defaultValue={[\"list\"]} aria-label=\"View\"><Toggle value=\"list\" aria-label=\"List view\"><Icon name=\"list\" /></Toggle><Toggle value=\"grid\" aria-label=\"Grid view\"><Icon name=\"grid\" /></Toggle></ToggleGroup>",
20554
22259
  "imports": "import { Icon, Toggle, ToggleGroup } from \"@sableui/react\""
20555
22260
  },
22261
+ {
22262
+ "label": "Segmented with labels, subtle motion",
22263
+ "code": "<ToggleGroup appearance=\"segmented\" motion=\"subtle\" defaultValue={[\"week\"]} aria-label=\"Range\"><Toggle value=\"day\">Day</Toggle><Toggle value=\"week\">Week</Toggle><Toggle value=\"month\">Month</Toggle></ToggleGroup>",
22264
+ "imports": "import { Toggle, ToggleGroup } from \"@sableui/react\""
22265
+ },
20556
22266
  {
20557
22267
  "label": "Multi-select group",
20558
22268
  "code": "<ToggleGroup multiple defaultValue={[\"bold\"]}><Toggle value=\"bold\">Bold</Toggle><Toggle value=\"italic\">Italic</Toggle></ToggleGroup>",
@@ -20745,73 +22455,302 @@
20745
22455
  },
20746
22456
  "tokensUsed": [
20747
22457
  "foreground",
20748
- "accent",
20749
- "accent-foreground",
20750
- "ring",
20751
- "hairline-strong",
20752
- "input",
20753
- "card"
22458
+ "accent",
22459
+ "accent-foreground",
22460
+ "ring",
22461
+ "hairline-strong",
22462
+ "input",
22463
+ "card"
22464
+ ],
22465
+ "motion": [
22466
+ {
22467
+ "slot": "arrival",
22468
+ "recipe": "none",
22469
+ "part": "Toolbar",
22470
+ "why": "A toolbar is fixed chrome. It is present when its surface is; animating it in would leave the tools briefly unusable for no reason the user can see."
22471
+ },
22472
+ {
22473
+ "slot": "response",
22474
+ "recipe": "motion-interactive",
22475
+ "part": "ToolbarButton",
22476
+ "why": "Inherited from buttonVariants: hover fill, focus ring and disabled dim ease together, so the item under the arrow keys reads as the one that moved."
22477
+ },
22478
+ {
22479
+ "slot": "response",
22480
+ "recipe": "motion-press",
22481
+ "part": "ToolbarButton",
22482
+ "why": "Inherited from buttonVariants. Many toolbar actions change something off to the side (the canvas, the selection), so the settle on press is the confirmation that lands where the pointer is."
22483
+ },
22484
+ {
22485
+ "slot": "transition",
22486
+ "recipe": "none",
22487
+ "part": "Toolbar",
22488
+ "why": "The toolbar has no state of its own. Pressed state belongs to a Toggle rendered through ToolbarButton, which carries its own transition."
22489
+ },
22490
+ {
22491
+ "slot": "activity",
22492
+ "recipe": "none",
22493
+ "part": "Toolbar",
22494
+ "why": "A toolbar never works on its own. Pending work belongs to the surface the action targets, or to a Spinner in the button that started it."
22495
+ }
22496
+ ],
22497
+ "a11y": [
22498
+ "Base UI renders role=\"toolbar\" with aria-orientation, and a roving tabindex: the toolbar is one tab stop, the arrow keys move between items, Home/End jump to the ends.",
22499
+ "Disabled items stay focusable by default and announce as disabled (aria-disabled), so arrowing along the row never silently skips a control. Set focusableWhenDisabled={false} on an item to opt out.",
22500
+ "ToolbarSeparator is role=\"separator\" with the orientation opposite the toolbar's, and is not a focus stop.",
22501
+ "ToolbarInput lets the arrow keys move the caret and only leaves the field at either end of its text.",
22502
+ "Always set aria-label on the Toolbar, and on every icon-only ToolbarButton.",
22503
+ "A Toggle rendered through ToolbarButton keeps aria-pressed; a MenuTrigger keeps aria-haspopup/aria-expanded."
22504
+ ],
22505
+ "examples": [
22506
+ {
22507
+ "label": "Formatting toolbar",
22508
+ "code": "<Toolbar aria-label=\"Text formatting\">\n <ToolbarGroup aria-label=\"History\">\n <ToolbarButton size=\"icon-sm\" aria-label=\"Undo\">\n <Icon name=\"arrow-left\" />\n </ToolbarButton>\n <ToolbarButton size=\"icon-sm\" aria-label=\"Redo\">\n <Icon name=\"arrow-right\" />\n </ToolbarButton>\n </ToolbarGroup>\n <ToolbarSeparator />\n <ToggleGroup multiple aria-label=\"Style\">\n <ToolbarButton size=\"icon-sm\" aria-label=\"Bold\" render={<Toggle value=\"bold\" />}>\n <Icon name=\"bold\" />\n </ToolbarButton>\n <ToolbarButton size=\"icon-sm\" aria-label=\"Italic\" render={<Toggle value=\"italic\" />}>\n <Icon name=\"italic\" />\n </ToolbarButton>\n </ToggleGroup>\n <ToolbarSeparator />\n <ToolbarLink href=\"/help\">Help</ToolbarLink>\n</Toolbar>",
22509
+ "imports": "import { Icon, Toggle, ToggleGroup, Toolbar, ToolbarButton, ToolbarGroup, ToolbarLink, ToolbarSeparator } from \"@sableui/react\""
22510
+ },
22511
+ {
22512
+ "label": "With a menu trigger and a search field",
22513
+ "code": "<Toolbar aria-label=\"Table\">\n <ToolbarButton>Export</ToolbarButton>\n <Menu>\n <ToolbarButton render={<MenuTrigger />}>View</ToolbarButton>\n <MenuContent>\n <MenuItem>Compact rows</MenuItem>\n <MenuItem>Comfortable rows</MenuItem>\n </MenuContent>\n </Menu>\n <ToolbarSeparator />\n <ToolbarInput aria-label=\"Search rows\" placeholder=\"Search\" />\n</Toolbar>",
22514
+ "imports": "import { Menu, MenuContent, MenuItem, MenuTrigger, Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from \"@sableui/react\""
22515
+ },
22516
+ {
22517
+ "label": "Vertical tool rail",
22518
+ "code": "<Toolbar orientation=\"vertical\" aria-label=\"Tools\">\n <ToolbarButton size=\"icon-sm\" aria-label=\"Select\">\n <Icon name=\"arrow-up\" />\n </ToolbarButton>\n <ToolbarButton size=\"icon-sm\" aria-label=\"Draw\">\n <Icon name=\"edit\" />\n </ToolbarButton>\n <ToolbarSeparator />\n <ToolbarButton size=\"icon-sm\" aria-label=\"Delete\" disabled>\n <Icon name=\"trash\" />\n </ToolbarButton>\n</Toolbar>",
22519
+ "imports": "import { Icon, Toolbar, ToolbarButton, ToolbarSeparator } from \"@sableui/react\""
22520
+ },
22521
+ {
22522
+ "label": "In a workspace bar",
22523
+ "code": "<WorkspaceBar>\n <Toolbar aria-label=\"Document\" className=\"flex-1\">\n <ToolbarButton size=\"xs\">File</ToolbarButton>\n <ToolbarButton size=\"xs\">Edit</ToolbarButton>\n <ToolbarButton size=\"icon-xs\" aria-label=\"Share\" className=\"ms-auto\">\n <Icon name=\"share\" />\n </ToolbarButton>\n </Toolbar>\n</WorkspaceBar>",
22524
+ "imports": "import { Icon, Toolbar, ToolbarButton, WorkspaceBar } from \"@sableui/react\""
22525
+ }
22526
+ ]
22527
+ },
22528
+ {
22529
+ "name": "ToolCall",
22530
+ "status": "stable",
22531
+ "group": "Chat & AI",
22532
+ "register": "either",
22533
+ "path": "components/tool-call",
22534
+ "summary": "One tool an agent called, as a quiet row inside its turn: a status glyph, what it did, the tool's name and where it is — with the arguments and result behind a disclosure.",
22535
+ "keywords": [
22536
+ "tool call",
22537
+ "function call",
22538
+ "tool use",
22539
+ "agent step",
22540
+ "mcp",
22541
+ "ai",
22542
+ "tool result",
22543
+ "agent activity"
22544
+ ],
22545
+ "usage": {
22546
+ "description": "Use it for each tool call in an assistant's turn, so the person can see what the agent did and check any step. The row is the summary — glyph, a plain-words title, the tool's name, the status — and stays one line, so a turn with ten calls is still scannable. Input and output sit behind the disclosure, because they are read rarely and, when they are, in full.",
22547
+ "bestPractices": [
22548
+ {
22549
+ "guidance": "do",
22550
+ "description": "Give it a `title` in plain words, in the tense of the status: \"Searching the docs\" while running, \"Searched the docs\" once done. The `name` stays beside it in mono for the person who wants to know exactly which tool ran."
22551
+ },
22552
+ {
22553
+ "guidance": "do",
22554
+ "description": "Drive `status` from the call's real state and keep the same element mounted as it changes. The spinner swaps for the outcome in place, and the change is announced to screen readers once."
22555
+ },
22556
+ {
22557
+ "guidance": "do",
22558
+ "description": "Pass `error` with `status=\"error\"`. It shows under the row without opening anything, because a failure is the one result everyone needs to read."
22559
+ },
22560
+ {
22561
+ "guidance": "do",
22562
+ "description": "Pass a rendered result as `children` when the output is something to look at — a list of hits, a diff — rather than data to inspect. It replaces the output's code view, and `input` still shows above it."
22563
+ },
22564
+ {
22565
+ "guidance": "dont",
22566
+ "description": "Do not put the approve and deny buttons in a ToolCall. A call held for permission is `status=\"awaiting\"` here, and the decision itself is ApprovalBlock, which says what will happen and asks once."
22567
+ },
22568
+ {
22569
+ "guidance": "dont",
22570
+ "description": "Do not use it for the agent's thinking or a progress note. A wait with no tool behind it is ThinkingOrb or a Spinner with a status line, and a row that claims a tool ran when none did makes every other row less trustworthy.",
22571
+ "instead": [
22572
+ "ThinkingOrb",
22573
+ "Spinner"
22574
+ ]
22575
+ },
22576
+ {
22577
+ "guidance": "dont",
22578
+ "description": "Do not open every call by default. A turn whose calls all show their JSON buries the answer; leave `defaultOpen` for the one call the person is likely to check, if any."
22579
+ }
22580
+ ],
22581
+ "anatomy": [
22582
+ {
22583
+ "name": "Status glyph",
22584
+ "required": true,
22585
+ "description": "Spinner while running, then the outcome: check, warning, clock (awaiting approval) or ×. The only part that moves."
22586
+ },
22587
+ {
22588
+ "name": "Title",
22589
+ "required": false,
22590
+ "description": "What the call does, in plain words. Without one, the tool's name leads."
22591
+ },
22592
+ {
22593
+ "name": "Name",
22594
+ "required": true,
22595
+ "description": "The tool's own name in mono, after the title. Hidden below the sm breakpoint when a title is present."
22596
+ },
22597
+ {
22598
+ "name": "Status",
22599
+ "required": true,
22600
+ "description": "One word for where the call is: Running, Needs approval, Done, Failed, Denied."
22601
+ },
22602
+ {
22603
+ "name": "Error line",
22604
+ "required": false,
22605
+ "description": "The failure message, under the row and always visible."
22606
+ },
22607
+ {
22608
+ "name": "Details",
22609
+ "required": false,
22610
+ "description": "Input and output as code, or a rendered result. A disclosure; the row is its trigger."
22611
+ }
22612
+ ]
22613
+ },
22614
+ "related": {
22615
+ "components": [
22616
+ "Message",
22617
+ "ThinkingOrb",
22618
+ "StreamingText",
22619
+ "CodeBlock",
22620
+ "Collapsible",
22621
+ "InlineStatus"
22622
+ ]
22623
+ },
22624
+ "props": [
22625
+ {
22626
+ "name": "name",
22627
+ "type": "string",
22628
+ "description": "The tool's own name, as the model called it (`search_docs`). Shown in mono."
22629
+ },
22630
+ {
22631
+ "name": "title",
22632
+ "type": "ReactNode",
22633
+ "description": "What the call does, in words a person reads. Leads the row; omit it and `name` leads."
22634
+ },
22635
+ {
22636
+ "name": "status",
22637
+ "type": "\"running\" | \"awaiting\" | \"complete\" | \"error\" | \"denied\"",
22638
+ "default": "\"running\"",
22639
+ "description": "Where the call is. `awaiting` is held for the person's approval; `denied` is one they declined."
22640
+ },
22641
+ {
22642
+ "name": "statusLabel",
22643
+ "type": "string",
22644
+ "description": "Overrides the status word, for another language or a more specific phrase."
22645
+ },
22646
+ {
22647
+ "name": "input",
22648
+ "type": "unknown",
22649
+ "description": "The arguments. A string shows as is; anything else as formatted JSON."
22650
+ },
22651
+ {
22652
+ "name": "output",
22653
+ "type": "unknown",
22654
+ "description": "The result, shown the same way as `input`."
22655
+ },
22656
+ {
22657
+ "name": "error",
22658
+ "type": "ReactNode",
22659
+ "description": "What went wrong, for `status=\"error\"`. Always visible under the row."
22660
+ },
22661
+ {
22662
+ "name": "children",
22663
+ "type": "ReactNode",
22664
+ "description": "A rendered result in place of `output`'s code view."
22665
+ },
22666
+ {
22667
+ "name": "defaultOpen",
22668
+ "type": "boolean",
22669
+ "default": "false",
22670
+ "description": "Open the details on mount."
22671
+ },
22672
+ {
22673
+ "name": "open",
22674
+ "type": "boolean",
22675
+ "description": "Controlled open state of the details."
22676
+ },
22677
+ {
22678
+ "name": "onOpenChange",
22679
+ "type": "(open: boolean) => void",
22680
+ "description": "Called when the details open or close."
22681
+ }
22682
+ ],
22683
+ "variants": {
22684
+ "status": [
22685
+ "running",
22686
+ "awaiting",
22687
+ "complete",
22688
+ "error",
22689
+ "denied"
22690
+ ]
22691
+ },
22692
+ "tokensUsed": [
22693
+ "card",
22694
+ "border",
22695
+ "destructive",
22696
+ "muted",
22697
+ "muted-foreground",
22698
+ "foreground",
22699
+ "tone-warning",
22700
+ "ring"
20754
22701
  ],
20755
22702
  "motion": [
20756
22703
  {
20757
22704
  "slot": "arrival",
20758
22705
  "recipe": "none",
20759
- "part": "Toolbar",
20760
- "why": "A toolbar is fixed chrome. It is present when its surface is; animating it in would leave the tools briefly unusable for no reason the user can see."
22706
+ "part": "Root",
22707
+ "why": "It arrives as part of the assistant's turn, and the turn's own arrival covers it. A second entrance per call would make a busy turn twitch."
20761
22708
  },
20762
22709
  {
20763
22710
  "slot": "response",
20764
22711
  "recipe": "motion-interactive",
20765
- "part": "ToolbarButton",
20766
- "why": "Inherited from buttonVariants: hover fill, focus ring and disabled dim ease together, so the item under the arrow keys reads as the one that moved."
22712
+ "part": "Header",
22713
+ "why": "The header is the disclosure trigger but reads as a status row. The hover step is what tells the person it opens."
20767
22714
  },
20768
22715
  {
20769
- "slot": "response",
20770
- "recipe": "motion-press",
20771
- "part": "ToolbarButton",
20772
- "why": "Inherited from buttonVariants. Many toolbar actions change something off to the side (the canvas, the selection), so the settle on press is the confirmation that lands where the pointer is."
22716
+ "slot": "transition",
22717
+ "recipe": "motion-icon-swap",
22718
+ "part": "Status glyph",
22719
+ "why": "The spinner becoming a check or a warning is the moment the call settles, and it is the one change in the row worth catching from the corner of the eye. The swap reads as one mark replaced, not a flicker."
20773
22720
  },
20774
22721
  {
20775
22722
  "slot": "transition",
20776
- "recipe": "none",
20777
- "part": "Toolbar",
20778
- "why": "The toolbar has no state of its own. Pressed state belongs to a Toggle rendered through ToolbarButton, which carries its own transition."
22723
+ "recipe": "motion-disclosure",
22724
+ "part": "Details",
22725
+ "why": "The height change connects the code that appears to the row that was pressed, in a thread where several rows look alike."
20779
22726
  },
20780
22727
  {
20781
22728
  "slot": "activity",
20782
- "recipe": "none",
20783
- "part": "Toolbar",
20784
- "why": "A toolbar never works on its own. Pending work belongs to the surface the action targets, or to a Spinner in the button that started it."
22729
+ "recipe": "motion-spin",
22730
+ "part": "Status glyph",
22731
+ "why": "While the call runs, the spinner is what separates working from stuck. It pauses once the call settles, so a long thread of finished calls costs nothing."
20785
22732
  }
20786
22733
  ],
20787
22734
  "a11y": [
20788
- "Base UI renders role=\"toolbar\" with aria-orientation, and a roving tabindex: the toolbar is one tab stop, the arrow keys move between items, Home/End jump to the ends.",
20789
- "Disabled items stay focusable by default and announce as disabled (aria-disabled), so arrowing along the row never silently skips a control. Set focusableWhenDisabled={false} on an item to opt out.",
20790
- "ToolbarSeparator is role=\"separator\" with the orientation opposite the toolbar's, and is not a focus stop.",
20791
- "ToolbarInput lets the arrow keys move the caret and only leaves the field at either end of its text.",
20792
- "Always set aria-label on the Toolbar, and on every icon-only ToolbarButton.",
20793
- "A Toggle rendered through ToolbarButton keeps aria-pressed; a MenuTrigger keeps aria-haspopup/aria-expanded."
22735
+ "With details, the header is a button with aria-expanded (Base UI Collapsible); its name is the title, the tool name and the status word, so it reads as one sentence.",
22736
+ "A status change after mount is announced once through a polite role=status region (\"Searched the docs: Done\"). The status a call mounts with is not announced, so a thread restored from history stays quiet.",
22737
+ "The glyph is aria-hidden: the status word beside it carries the meaning, so colour and shape are never the only signal."
20794
22738
  ],
20795
22739
  "examples": [
20796
22740
  {
20797
- "label": "Formatting toolbar",
20798
- "code": "<Toolbar aria-label=\"Text formatting\">\n <ToolbarGroup aria-label=\"History\">\n <ToolbarButton size=\"icon-sm\" aria-label=\"Undo\">\n <Icon name=\"arrow-left\" />\n </ToolbarButton>\n <ToolbarButton size=\"icon-sm\" aria-label=\"Redo\">\n <Icon name=\"arrow-right\" />\n </ToolbarButton>\n </ToolbarGroup>\n <ToolbarSeparator />\n <ToggleGroup multiple aria-label=\"Style\">\n <ToolbarButton size=\"icon-sm\" aria-label=\"Bold\" render={<Toggle value=\"bold\" />}>\n <Icon name=\"bold\" />\n </ToolbarButton>\n <ToolbarButton size=\"icon-sm\" aria-label=\"Italic\" render={<Toggle value=\"italic\" />}>\n <Icon name=\"italic\" />\n </ToolbarButton>\n </ToggleGroup>\n <ToolbarSeparator />\n <ToolbarLink href=\"/help\">Help</ToolbarLink>\n</Toolbar>",
20799
- "imports": "import { Icon, Toggle, ToggleGroup, Toolbar, ToolbarButton, ToolbarGroup, ToolbarLink, ToolbarSeparator } from \"@sableui/react\""
20800
- },
20801
- {
20802
- "label": "With a menu trigger and a search field",
20803
- "code": "<Toolbar aria-label=\"Table\">\n <ToolbarButton>Export</ToolbarButton>\n <Menu>\n <ToolbarButton render={<MenuTrigger />}>View</ToolbarButton>\n <MenuContent>\n <MenuItem>Compact rows</MenuItem>\n <MenuItem>Comfortable rows</MenuItem>\n </MenuContent>\n </Menu>\n <ToolbarSeparator />\n <ToolbarInput aria-label=\"Search rows\" placeholder=\"Search\" />\n</Toolbar>",
20804
- "imports": "import { Menu, MenuContent, MenuItem, MenuTrigger, Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from \"@sableui/react\""
22741
+ "label": "A finished call",
22742
+ "code": "<ToolCall\n\tname=\"search_docs\"\n\ttitle=\"Searched the docs\"\n\tstatus=\"complete\"\n\tinput={{ query: \"date range picker\" }}\n\toutput={{ hits: [\"DatePicker\", \"Calendar\", \"DateTile\"] }}\n/>",
22743
+ "imports": "import { ToolCall } from \"@sableui/react\""
20805
22744
  },
20806
22745
  {
20807
- "label": "Vertical tool rail",
20808
- "code": "<Toolbar orientation=\"vertical\" aria-label=\"Tools\">\n <ToolbarButton size=\"icon-sm\" aria-label=\"Select\">\n <Icon name=\"arrow-up\" />\n </ToolbarButton>\n <ToolbarButton size=\"icon-sm\" aria-label=\"Draw\">\n <Icon name=\"edit\" />\n </ToolbarButton>\n <ToolbarSeparator />\n <ToolbarButton size=\"icon-sm\" aria-label=\"Delete\" disabled>\n <Icon name=\"trash\" />\n </ToolbarButton>\n</Toolbar>",
20809
- "imports": "import { Icon, Toolbar, ToolbarButton, ToolbarSeparator } from \"@sableui/react\""
22746
+ "label": "Running, then failed",
22747
+ "code": "<div className=\"flex flex-col gap-2\">\n\t<ToolCall name=\"read_file\" title=\"Reading package.json\" status=\"running\" input={{ path: \"package.json\" }} />\n\t<ToolCall\n\t\tname=\"run_tests\"\n\t\ttitle=\"Ran the tests\"\n\t\tstatus=\"error\"\n\t\terror=\"3 of 412 tests failed in date-picker.test.tsx.\"\n\t\tinput={{ filter: \"date-picker\" }}\n\t/>\n</div>",
22748
+ "imports": "import { ToolCall } from \"@sableui/react\""
20810
22749
  },
20811
22750
  {
20812
- "label": "In a workspace bar",
20813
- "code": "<WorkspaceBar>\n <Toolbar aria-label=\"Document\" className=\"flex-1\">\n <ToolbarButton size=\"xs\">File</ToolbarButton>\n <ToolbarButton size=\"xs\">Edit</ToolbarButton>\n <ToolbarButton size=\"icon-xs\" aria-label=\"Share\" className=\"ms-auto\">\n <Icon name=\"share\" />\n </ToolbarButton>\n </Toolbar>\n</WorkspaceBar>",
20814
- "imports": "import { Icon, Toolbar, ToolbarButton, WorkspaceBar } from \"@sableui/react\""
22751
+ "label": "Waiting for approval",
22752
+ "code": "<ToolCall name=\"send_email\" title=\"Send the invoice to Ada\" status=\"awaiting\" input={{ to: \"ada@example.com\", subject: \"INV-1042\" }} />",
22753
+ "imports": "import { ToolCall } from \"@sableui/react\""
20815
22754
  }
20816
22755
  ]
20817
22756
  },
@@ -20923,7 +22862,12 @@
20923
22862
  {
20924
22863
  "name": "SimpleTooltip",
20925
22864
  "type": "Base UI Tooltip.Root props + content: ReactNode + children: ReactElement + side | align | sideOffset",
20926
- "description": "Shorthand wrapping Root + Trigger + Content for the common case. The single child becomes the trigger (via Base UI render); content sets the body; side / align / sideOffset forward to the content with the same defaults. Composition parts remain for advanced use."
22865
+ "description": "Shorthand wrapping Root + Trigger + Content for the common case. The single child becomes the trigger (via Base UI render); content sets the body; side / align / sideOffset forward to the content with the same defaults. Composition parts remain for advanced use. Inside a TooltipGroup it becomes a trigger of the group's shared tooltip instead."
22866
+ },
22867
+ {
22868
+ "name": "TooltipGroup",
22869
+ "type": "{ children: ReactNode; motion?: \"full\" | \"subtle\" | \"off\" }",
22870
+ "description": "One tooltip shared by every SimpleTooltip inside it — for a toolbar or a row of icon buttons. Moving the pointer along the row glides the tooltip to the next trigger and swaps its label (old fades out, new fades in, each sliding toward the direction of travel), instead of closing one tooltip and opening another a few pixels away. Renders no element. Each SimpleTooltip keeps its own content / side / align / sideOffset and `disabled`; root props such as open / onOpenChange belong to the shared tooltip and are ignored inside a group. `motion` turns it down: `subtle` jumps between triggers and only cross-fades the label. The composable Tooltip parts are unaffected."
20927
22871
  }
20928
22872
  ],
20929
22873
  "tokensUsed": [
@@ -20955,6 +22899,12 @@
20955
22899
  "recipe": "none",
20956
22900
  "part": "Content",
20957
22901
  "why": "Tooltip copy is resident before the tooltip opens. Anything that has to be fetched is a Popover with its own loading state."
22902
+ },
22903
+ {
22904
+ "slot": "transition",
22905
+ "recipe": "custom",
22906
+ "part": "TooltipGroup",
22907
+ "why": "Moving between triggers in a group, the one tooltip glides to the next trigger, resizes to its label and cross-fades the text with a short slide toward the direction of travel. Separate tooltips would close here and open there, two surfaces blinking side by side; the glide says it is the same hint following the pointer. Base UI's handle + Viewport supply the shared popup and the previous/current content; the timing is the expressive glide (slow, emphasized), so `subtle` keeps only the cross-fade."
20958
22908
  }
20959
22909
  ],
20960
22910
  "a11y": [
@@ -20978,6 +22928,11 @@
20978
22928
  "label": "Shorthand (SimpleTooltip)",
20979
22929
  "code": "<SimpleTooltip content=\"Saved to your workspace.\"><Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Info\"><Icon name=\"info\" /></Button></SimpleTooltip>",
20980
22930
  "imports": "import { Button, Icon, SimpleTooltip } from \"@sableui/react\""
22931
+ },
22932
+ {
22933
+ "label": "One tooltip gliding along a toolbar (TooltipGroup)",
22934
+ "code": "<TooltipGroup><Toolbar aria-label=\"Formatting\"><SimpleTooltip content=\"Bold\"><ToolbarButton size=\"icon-sm\" aria-label=\"Bold\"><Icon name=\"bold\" /></ToolbarButton></SimpleTooltip><SimpleTooltip content=\"Italic\"><ToolbarButton size=\"icon-sm\" aria-label=\"Italic\"><Icon name=\"italic\" /></ToolbarButton></SimpleTooltip><SimpleTooltip content=\"Insert link\"><ToolbarButton size=\"icon-sm\" aria-label=\"Insert link\"><Icon name=\"link\" /></ToolbarButton></SimpleTooltip></Toolbar></TooltipGroup>",
22935
+ "imports": "import { Icon, SimpleTooltip, Toolbar, ToolbarButton, TooltipGroup } from \"@sableui/react\""
20981
22936
  }
20982
22937
  ]
20983
22938
  },
@@ -21158,6 +23113,12 @@
21158
23113
  "name": "label",
21159
23114
  "type": "ReactNode",
21160
23115
  "description": "TreeAddRow's visible label. Name what gets added — 'Add effect', not 'Add'."
23116
+ },
23117
+ {
23118
+ "name": "motion",
23119
+ "type": "\"full\" | \"subtle\" | \"off\"",
23120
+ "default": "\"full\"",
23121
+ "description": "On Tree. How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down — it never overrides a reader's reduce-motion setting. Under `subtle` expanded rows appear at once instead of unfolding; the chevron still turns."
21161
23122
  }
21162
23123
  ],
21163
23124
  "subcomponents": [
@@ -21195,6 +23156,32 @@
21195
23156
  "primary",
21196
23157
  "ring"
21197
23158
  ],
23159
+ "motion": [
23160
+ {
23161
+ "slot": "arrival",
23162
+ "recipe": "motion-arrive",
23163
+ "part": "TreeRow",
23164
+ "why": "Rows a user's expand reveals fade down into place in document order (a capped 18ms stagger), so the eye can follow where the children came from. An animation (motion-arrive) rather than a transition, because the rows already wear motion-interactive and a second transition list would fight it. Only on a user expand of a `subtree` — a tree that mounts open shows its rows at once, and rows added later to an open folder do not cascade. Expressive: `subtle` shows them at once."
23165
+ },
23166
+ {
23167
+ "slot": "response",
23168
+ "recipe": "motion-interactive",
23169
+ "part": "TreeRow",
23170
+ "why": "Hover and selection fills ease, so sweeping the pointer down a dense panel reads as a highlight following it rather than rows flickering."
23171
+ },
23172
+ {
23173
+ "slot": "transition",
23174
+ "recipe": "motion-interactive",
23175
+ "part": "Disclosure",
23176
+ "why": "The chevron turns with the rows arriving under it; motion-interactive lists `rotate`, which is what -rotate-90 writes, so it no longer flips in one frame."
23177
+ },
23178
+ {
23179
+ "slot": "activity",
23180
+ "recipe": "none",
23181
+ "part": "Tree",
23182
+ "why": "A tree has no indeterminate state of its own; a branch that loads its children should render a TreeEmpty or a loading row inside it."
23183
+ }
23184
+ ],
21198
23185
  "a11y": [
21199
23186
  "Tree implements the ARIA tree view pattern: Tree is role=\"tree\", each TreeRow and TreeAddRow is role=\"treeitem\" with aria-level, expandable rows carry aria-expanded, and a TreeSection is a role=\"group\" labelled by its heading. Rows stay flat in the DOM — hierarchy is aria-level — so the fills span the panel's full width.",
21200
23187
  "Keyboard: exactly one row is in the tab order (roving tabindex, seeded on the selected row, else the first). ArrowUp/Down move between visible rows; ArrowRight expands a collapsed row, then steps to its first child; ArrowLeft collapses an expanded row, else steps to its parent; Home/End jump to the ends; Enter/Space activate the row — its onClick, or toggling it when it has none. Keys pressed inside a row's own controls (an action button, a rename field) are left to that control.",
@@ -21309,6 +23296,38 @@
21309
23296
  "overlay-foreground",
21310
23297
  "ring"
21311
23298
  ],
23299
+ "motion": [
23300
+ {
23301
+ "slot": "arrival",
23302
+ "recipe": "none",
23303
+ "part": "Root",
23304
+ "why": "The frame arrives with its page. Once there is a source, the poster and controls are the browser's."
23305
+ },
23306
+ {
23307
+ "slot": "response",
23308
+ "recipe": "motion-interactive",
23309
+ "part": "Play button",
23310
+ "why": "With `onRequestPlay`, the play disc firms its scrim on hover, eased, so the mark reads as pressable over any image."
23311
+ },
23312
+ {
23313
+ "slot": "response",
23314
+ "recipe": "motion-press",
23315
+ "part": "Play button",
23316
+ "why": "Loading a source can take a moment; the settle on press confirms the tap before the player appears."
23317
+ },
23318
+ {
23319
+ "slot": "transition",
23320
+ "recipe": "none",
23321
+ "part": "Root",
23322
+ "why": "Placeholder to player is a swap of elements: the native video mounts where the poster was. Animating the swap would delay the playback the person just asked for."
23323
+ },
23324
+ {
23325
+ "slot": "activity",
23326
+ "recipe": "none",
23327
+ "part": "Root",
23328
+ "why": "Buffering belongs to the native controls, which draw their own indicator. A second loader over the frame would compete with the browser's."
23329
+ }
23330
+ ],
21312
23331
  "a11y": [
21313
23332
  "Mounts a native <video> element, preserving built-in keyboard controls when controls is enabled.",
21314
23333
  "Without onRequestPlay the placeholder play mark is decorative (aria-hidden) — nothing on the frame pretends to be pressable. With it, the mark is a native button named \"Play video\"."
@@ -21742,6 +23761,31 @@
21742
23761
  "imports": "import { Button, Card, CardAction, CardDescription, CardHeader, CardList, CardTitle, cn, Code, Entity, Icon, MenuItem, MenuSeparator, OverflowMenu } from \"@sableui/react\"",
21743
23762
  "code": "const keys = [\n\t{ id: \"k1\", name: \"Production\", hint: \"sk_live_••••4f2a\", detail: \"Created Mar 2 · used 4 minutes ago\" },\n\t{ id: \"k2\", name: \"Staging\", hint: \"sk_test_••••91cd\", detail: \"Created Feb 11 · used yesterday\" },\n\t{ id: \"k3\", name: \"CI deploys\", hint: \"sk_live_••••0b7e\", detail: \"Created Jan 5 · never used\" },\n]\n\nexport interface ApiKeysBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n\tonCreate?: () => void\n\t/** Called with the key's id and the action chosen from its menu. */\n\tonKeyAction?: (id: string, action: \"rename\" | \"roll\" | \"revoke\") => void\n}\n\nexport function ApiKeysBlock({ className, framed = true, onCreate, onKeyAction }: ApiKeysBlockProps) {\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-2xl\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle render={<h2 />}>API keys</CardTitle>\n\t\t\t\t<CardDescription>A key is shown once, when you create it. Store it somewhere safe.</CardDescription>\n\t\t\t\t<CardAction>\n\t\t\t\t\t<Button size=\"sm\" variant=\"outline\" iconStart={<Icon name=\"key\" />} onClick={onCreate}>\n\t\t\t\t\t\tCreate key\n\t\t\t\t\t</Button>\n\t\t\t\t</CardAction>\n\t\t\t</CardHeader>\n\t\t\t<CardList>\n\t\t\t\t{keys.map((k) => (\n\t\t\t\t\t<Entity\n\t\t\t\t\t\tkey={k.id}\n\t\t\t\t\t\ttitle={\n\t\t\t\t\t\t\t<span className=\"flex flex-wrap items-center gap-x-2\">\n\t\t\t\t\t\t\t\t{k.name}\n\t\t\t\t\t\t\t\t<Code>{k.hint}</Code>\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t}\n\t\t\t\t\t\tsubtitle={k.detail}\n\t\t\t\t\t\taction={\n\t\t\t\t\t\t\t<OverflowMenu label={`Manage the ${k.name} key`} size=\"icon-sm\">\n\t\t\t\t\t\t\t\t<MenuItem onClick={() => onKeyAction?.(k.id, \"rename\")}>Rename</MenuItem>\n\t\t\t\t\t\t\t\t<MenuItem onClick={() => onKeyAction?.(k.id, \"roll\")}>Roll key</MenuItem>\n\t\t\t\t\t\t\t\t<MenuSeparator />\n\t\t\t\t\t\t\t\t<MenuItem variant=\"destructive\" onClick={() => onKeyAction?.(k.id, \"revoke\")}>\n\t\t\t\t\t\t\t\t\tRevoke\n\t\t\t\t\t\t\t\t</MenuItem>\n\t\t\t\t\t\t\t</OverflowMenu>\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t))}\n\t\t\t</CardList>\n\t\t</Card>\n\t)\n}"
21744
23763
  },
23764
+ {
23765
+ "name": "ApprovalBlock",
23766
+ "path": "blocks/approval-block.tsx",
23767
+ "summary": "An agent asking permission for one tool call: the question, what will happen and what can't be undone, the call's details, an \"allow for the rest of this chat\" Checkbox, and Deny / Allow.",
23768
+ "intent": "Reach for this in a chat or agent run when a tool call waits on the person — sending, paying, deleting, publishing. The call itself, before and after the decision, is a ToolCall with status=\"awaiting\", then \"complete\" or \"denied\". A destructive action outside an agent is DestructiveActionModal.",
23769
+ "family": "AI & agents",
23770
+ "kind": "card",
23771
+ "width": {
23772
+ "min": "18rem",
23773
+ "max": "28rem"
23774
+ },
23775
+ "notes": [
23776
+ "The title is the question in plain words (\"Send the invoice to Ada?\"), not the tool's name; the tool's name is one row of the details, for the person who wants to know exactly what runs.",
23777
+ "The description says what can't be undone. That sentence is what the person is deciding on.",
23778
+ "Deny comes first and Allow is the primary on the end, the same order as every Sable confirmation. Neither is focused on arrival: an approval that a stray Enter can grant is not an approval.",
23779
+ "After a decision the buttons give way to one InlineStatus line saying what happened, so the thread keeps a record of the answer."
23780
+ ],
23781
+ "a11y": [
23782
+ "Focus moves to the outcome line when the buttons leave, instead of dropping to the page; the line is a role=status region, so the decision is announced.",
23783
+ "The details are a dl, so each value is read with its label.",
23784
+ "The remember Checkbox has its own Label, so the tool name inside it is part of its accessible name."
23785
+ ],
23786
+ "imports": "import { useState, useEffect, useRef, useId } from \"react\"\nimport { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, cn, Code, InlineStatus, Label } from \"@sableui/react\"",
23787
+ "code": "const request = {\n\ttool: \"send_email\",\n\ttitle: \"Send the invoice to Ada?\",\n\tdescription: \"The agent wants to email invoice INV-1042. An email can't be unsent.\",\n\tdetails: [\n\t\t{ label: \"To\", value: \"ada@example.com\" },\n\t\t{ label: \"Subject\", value: \"INV-1042 for October\" },\n\t\t{ label: \"Attached\", value: \"INV-1042.pdf\" },\n\t],\n\tallowed: \"Allowed. Sending the email.\",\n\tdenied: \"Denied. The agent won't send it.\",\n}\n\nexport interface ApprovalBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a chat column that already frames it. */\n\tframed?: boolean\n\t/** The person allowed the call. `remember` is the \"for the rest of this chat\" choice. */\n\tonApprove?: (options: { remember: boolean }) => void\n\t/** The person declined the call. */\n\tonDeny?: () => void\n}\n\nexport function ApprovalBlock({ className, framed = true, onApprove, onDeny }: ApprovalBlockProps) {\n\tconst [decision, setDecision] = useState<\"allowed\" | \"denied\" | null>(null)\n\tconst [remember, setRemember] = useState(false)\n\tconst id = useId()\n\tconst outcome = useRef<HTMLDivElement>(null)\n\t// The buttons leave with the decision, so focus moves to the line that\n\t// replaces them instead of falling back to the top of the page.\n\tuseEffect(() => {\n\t\tif (decision) outcome.current?.focus()\n\t}, [decision])\n\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-md\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle>{request.title}</CardTitle>\n\t\t\t\t<CardDescription>{request.description}</CardDescription>\n\t\t\t</CardHeader>\n\t\t\t<CardContent className=\"flex flex-col gap-4\">\n\t\t\t\t<dl className=\"grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-body-sm\">\n\t\t\t\t\t{request.details.map((d) => (\n\t\t\t\t\t\t<div key={d.label} className=\"contents\">\n\t\t\t\t\t\t\t<dt className=\"text-muted-foreground\">{d.label}</dt>\n\t\t\t\t\t\t\t<dd className=\"truncate text-foreground\">{d.value}</dd>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t\t<div className=\"contents\">\n\t\t\t\t\t\t<dt className=\"text-muted-foreground\">Tool</dt>\n\t\t\t\t\t\t<dd className=\"min-w-0\">\n\t\t\t\t\t\t\t<Code>{request.tool}</Code>\n\t\t\t\t\t\t</dd>\n\t\t\t\t\t</div>\n\t\t\t\t</dl>\n\t\t\t\t{decision ? null : (\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<Checkbox id={`${id}-remember`} checked={remember} onCheckedChange={(v: boolean) => setRemember(v)} />\n\t\t\t\t\t\t<Label htmlFor={`${id}-remember`} className=\"text-body-sm\">\n\t\t\t\t\t\t\tAllow <Code>{request.tool}</Code> for the rest of this chat\n\t\t\t\t\t\t</Label>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</CardContent>\n\t\t\t<CardFooter actions className=\"justify-end\">\n\t\t\t\t{decision ? (\n\t\t\t\t\t<InlineStatus\n\t\t\t\t\t\tref={outcome}\n\t\t\t\t\t\ttabIndex={-1}\n\t\t\t\t\t\trole=\"status\"\n\t\t\t\t\t\ttone={decision === \"allowed\" ? \"success\" : \"neutral\"}\n\t\t\t\t\t\tclassName=\"me-auto outline-none\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{decision === \"allowed\" ? request.allowed : request.denied}\n\t\t\t\t\t</InlineStatus>\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tvariant=\"outline\"\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetDecision(\"denied\")\n\t\t\t\t\t\t\t\tonDeny?.()\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tDeny\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tsetDecision(\"allowed\")\n\t\t\t\t\t\t\t\tonApprove?.({ remember })\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tAllow\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
23788
+ },
21745
23789
  {
21746
23790
  "name": "ChatThreadBlock",
21747
23791
  "path": "blocks/chat-thread-block.tsx",
@@ -23225,8 +25269,8 @@
23225
25269
  {
23226
25270
  "slug": "motion",
23227
25271
  "title": "Motion",
23228
- "summary": "Motion explains a change: where something came from, what responded, and that work is happening. It is subtle by default, built from named recipes, and one switch turns it off.",
23229
- "headline": "4 slots · 3 speeds · enter-only overshoot · 1 kill switch",
25272
+ "summary": "Motion explains a change: where something came from, what responded, and that work is happening. Components ship crafted by default: one indicator travels instead of two blinking, numbers roll, ticks draw, a refused answer shakes. It is built from named recipes, comes in two levels, and one switch turns it off.",
25273
+ "headline": "4 slots · 3 speeds · 2 levels · enter-only overshoot · 1 kill switch",
23230
25274
  "retrieve": "sable docs motion",
23231
25275
  "vocabularies": [
23232
25276
  {
@@ -23287,7 +25331,37 @@
23287
25331
  {
23288
25332
  "name": "motion-icon-swap",
23289
25333
  "value": "enter · emphasized cross-fade",
23290
- "use": "Two icons of one control trading places: play/pause, outline/fill."
25334
+ "use": "Two icons of one control trading places: play/pause, outline/fill, copy/check."
25335
+ },
25336
+ {
25337
+ "name": "motion-glide",
25338
+ "value": "slow · emphasized · expressive",
25339
+ "use": "ONE indicator travelling to the selected option: tabs, segmented thumb, active dot."
25340
+ },
25341
+ {
25342
+ "name": "motion-shake",
25343
+ "value": "~400ms decaying · expressive",
25344
+ "use": "A refused answer: wrong code, invalid submit. Plays while data-shake is set (useShake). Never the only signal."
25345
+ },
25346
+ {
25347
+ "name": "motion-tick",
25348
+ "value": "fast fade + base wipe · expressive",
25349
+ "use": "A check mark that draws itself in. Exits only fade."
25350
+ },
25351
+ {
25352
+ "name": "motion-appear",
25353
+ "value": "fast fade + base scale",
25354
+ "use": "A small control mounting in place: a clear button, a send arrow, a badge. @starting-style, no JS."
25355
+ },
25356
+ {
25357
+ "name": "motion-arrive",
25358
+ "value": "base · decelerate · staggered · expressive",
25359
+ "use": "Items a user action brings into a list: a folder's children on expand, a row just added. Never on every render."
25360
+ },
25361
+ {
25362
+ "name": "motion-size",
25363
+ "value": "base · emphasized · expressive",
25364
+ "use": "An element easing between intrinsic sizes when its label changes: a two-step confirm, Copy → Copied."
23291
25365
  },
23292
25366
  {
23293
25367
  "name": "motion-draw",
@@ -23331,6 +25405,42 @@
23331
25405
  }
23332
25406
  ]
23333
25407
  },
25408
+ {
25409
+ "title": "Two levels",
25410
+ "note": "Essential motion answers the user; expressive motion narrates continuity. Expressive durations are also multiplied by --motion-expressive, which `subtle` sets to 0. Each level can only turn motion down from what surrounds it.",
25411
+ "terms": [
25412
+ {
25413
+ "name": "essential",
25414
+ "value": "always, unless off",
25415
+ "use": "Colour and focus feedback, press, overlay arrival, disclosure height, loaders, chart hover."
25416
+ },
25417
+ {
25418
+ "name": "expressive",
25419
+ "value": "full only",
25420
+ "use": "Glides, rolling numbers (NumberTicker), drawn ticks, shakes, staggers, chart arrival and morph, scroll reveals, spring overshoot."
25421
+ },
25422
+ {
25423
+ "name": "full",
25424
+ "value": "default",
25425
+ "use": "Both levels play. The crafted default."
25426
+ },
25427
+ {
25428
+ "name": "subtle",
25429
+ "value": "data-motion=\"subtle\"",
25430
+ "use": "Essential only; nothing overshoots. For dense tools, embedded widgets, readers who want calm without stillness."
25431
+ },
25432
+ {
25433
+ "name": "off",
25434
+ "value": "data-motion=\"off\" · reduce-motion",
25435
+ "use": "Nothing moves; every state still changes."
25436
+ },
25437
+ {
25438
+ "name": "where",
25439
+ "value": "provider · scope · prop",
25440
+ "use": "<MotionProvider policy> for the app, <MotionScope level> for a region, `motion` on a component that owns a flourish."
25441
+ }
25442
+ ]
25443
+ },
23334
25444
  {
23335
25445
  "title": "The four slots",
23336
25446
  "note": "Every interactive component answers all four, by filling the slot or declining it with a reason in meta.motion.",
@@ -23389,6 +25499,22 @@
23389
25499
  {
23390
25500
  "rule": "JS motion reads useMotion().enabled and does nothing when it is false.",
23391
25501
  "why": "Reduced motion is an accessibility setting, not a style preference."
25502
+ },
25503
+ {
25504
+ "rule": "One thing travels; two things do not blink. When a selection, a value or a mark changes in place, move the one element that represents it.",
25505
+ "why": "A fill switching off here and on there is two events the eye has to connect. A glide, a roll or a draw is one, and it is most of what makes an interface feel made rather than assembled."
25506
+ },
25507
+ {
25508
+ "rule": "Flourishes are expressive: multiply their durations by --motion-expressive and gate JS ones on useMotion().expressive.",
25509
+ "why": "`subtle` promises the answers without the choreography. A glide on a plain token keeps playing after someone asked for calm."
25510
+ },
25511
+ {
25512
+ "rule": "A state change finishes its story: refused shakes and says why, success confirms in place, armed asks again.",
25513
+ "why": "Motion that starts a change and leaves the outcome to a snap reads as unfinished. ConfirmButton, OtpField's invalid shake and the copy-to-check swap are the reference cases."
25514
+ },
25515
+ {
25516
+ "rule": "Component motion props and MotionScope only turn motion down.",
25517
+ "why": "`full` on a component means inherit. Nothing below the provider can switch motion back on for a reader who asked for it off."
23392
25518
  }
23393
25519
  ],
23394
25520
  "noGos": [
@@ -24466,6 +26592,126 @@
24466
26592
  "motion-interactive"
24467
26593
  ]
24468
26594
  },
26595
+ {
26596
+ "name": "motion-glide",
26597
+ "kind": "utility",
26598
+ "family": "motion",
26599
+ "intent": "Timing for ONE element that travels to the selected option — position and size ease on the emphasized curve at the slow step. Expressive: `data-motion=\"subtle\"` makes it snap.",
26600
+ "whenToUse": "A selection indicator you position yourself: a custom nav's current-page bar, a segmented control's thumb, an active dot. Tabs, ToggleGroup `segmented`, ThemeSwitcher and Carousel already glide.",
26601
+ "insteadOf": "A fill or border on each option that switches on and off (two things blink instead of one thing moving), or a hand-written transition with a literal duration.",
26602
+ "example": "<span aria-hidden className=\"motion-glide absolute bottom-0 h-0.5 bg-foreground\" style={{ left: x, width: w }} />",
26603
+ "keywords": [
26604
+ "sliding indicator",
26605
+ "tab indicator",
26606
+ "segmented thumb",
26607
+ "active indicator",
26608
+ "glide",
26609
+ "underline animation",
26610
+ "selection animation"
26611
+ ],
26612
+ "pairsWith": [
26613
+ "motion-interactive"
26614
+ ]
26615
+ },
26616
+ {
26617
+ "name": "motion-shake",
26618
+ "kind": "utility",
26619
+ "family": "motion",
26620
+ "intent": "A short decaying horizontal shake that plays while `data-shake` is set. `useShake()` sets and clears the attribute and restarts it on a repeat refusal. Expressive: under `subtle` only the error colour remains.",
26621
+ "whenToUse": "An answer the system refused: a wrong one-time code, a submit with invalid fields, a rejected password. Always alongside the error colour and message, never instead of them. OtpField's `invalid` already shakes.",
26622
+ "insteadOf": "A keyframe shake with a literal duration, which keeps moving under reduced motion, or a shake with no error text.",
26623
+ "example": "const { shake, shakeProps } = useShake()\n<form className=\"motion-shake\" {...shakeProps} onSubmit={(e) => { if (!valid) { e.preventDefault(); shake() } }}>…</form>",
26624
+ "keywords": [
26625
+ "shake",
26626
+ "error animation",
26627
+ "wrong password",
26628
+ "invalid",
26629
+ "refuse",
26630
+ "wiggle",
26631
+ "nope"
26632
+ ],
26633
+ "pairsWith": [
26634
+ "motion-interactive"
26635
+ ]
26636
+ },
26637
+ {
26638
+ "name": "motion-tick",
26639
+ "kind": "utility",
26640
+ "family": "motion",
26641
+ "intent": "A selection mark that draws itself in from its leading edge (a clip-path wipe, so it works on any icon set) with an essential fade, keyed to Base UI's starting/ending style. Exits only fade. The wipe is expressive.",
26642
+ "whenToUse": "A check or success glyph that mounts with its state: a custom selection card's tick, a completed step. Checkbox and CheckboxCard already use it.",
26643
+ "insteadOf": "A scale-from-zero pop on a check mark, which reads as a stamp rather than a tick, or a path-length draw that needs to own the SVG.",
26644
+ "example": "<Base.Indicator className=\"motion-tick\"><Icon name=\"check\" /></Base.Indicator>",
26645
+ "keywords": [
26646
+ "check animation",
26647
+ "tick",
26648
+ "draw check",
26649
+ "success mark",
26650
+ "checkbox animation",
26651
+ "selection mark"
26652
+ ]
26653
+ },
26654
+ {
26655
+ "name": "motion-appear",
26656
+ "kind": "utility",
26657
+ "family": "motion",
26658
+ "intent": "Fades and scales a small control in on first render through `@starting-style` — no data attribute, no JS. The fade is essential, the scale is expressive. Removal is instant.",
26659
+ "whenToUse": "A control that mounts in place as state changes: a search field's clear button, a send button replacing a mic, a count badge that appears. Put it on a wrapper around a Sable Button or anything carrying `motion-interactive`, so the two do not fight over `transition-property`.",
26660
+ "insteadOf": "Conditionally rendering a control so it pops in on one frame, or wrapping a single icon button in Presence.",
26661
+ "example": "{value && <span className=\"flex motion-appear\"><Button size=\"icon-xs\" variant=\"ghost\" aria-label=\"Clear\"><Icon name=\"xmark\" /></Button></span>}",
26662
+ "keywords": [
26663
+ "appear",
26664
+ "mount animation",
26665
+ "pop in",
26666
+ "enter",
26667
+ "starting style",
26668
+ "clear button"
26669
+ ],
26670
+ "pairsWith": [
26671
+ "motion-icon-swap"
26672
+ ]
26673
+ },
26674
+ {
26675
+ "name": "motion-arrive",
26676
+ "kind": "utility",
26677
+ "family": "motion",
26678
+ "intent": "Items a user action brings into a list settle into place one after another: a short fade down from 0.25rem. Stagger with `--motion-arrive-delay` (raw ms; the recipe scales it). Expressive.",
26679
+ "whenToUse": "Only on items that are NEW because of something the user did: a folder's children on expand, a row just added, results that load in. Tree's expand already uses it.",
26680
+ "insteadOf": "A staggered entrance on every render of a list, which turns a list into a loading delay, or an inline keyframe animation with literal durations.",
26681
+ "example": "<li className=\"motion-arrive\" style={{ \"--motion-arrive-delay\": \"36ms\" } as CSSProperties}>{row.name}</li>",
26682
+ "keywords": [
26683
+ "stagger",
26684
+ "list animation",
26685
+ "arrival",
26686
+ "cascade",
26687
+ "new item",
26688
+ "expand children",
26689
+ "enter list"
26690
+ ],
26691
+ "pairsWith": [
26692
+ "motion-interactive"
26693
+ ]
26694
+ },
26695
+ {
26696
+ "name": "motion-size",
26697
+ "kind": "utility",
26698
+ "family": "motion",
26699
+ "intent": "Eases an element between sizes: between a keyword and a length (0 ↔ auto) through `interpolate-size: allow-keywords`, and between measured pixel sizes when paired with `useAutoSize()`, so a button whose label changes stretches to the new width instead of jumping. A superset of motion-interactive's transition list, so it can sit on a Button. The size is expressive.",
26700
+ "whenToUse": "A control whose content changes length in place: a two-step confirm button, a Copy → Copied label, a chip that gains a count. ConfirmButton already uses it.",
26701
+ "insteadOf": "A hand-written width transition, or a fixed width that clips the longer label. A content-sized box computes `width: auto` before and after its label changes, so on its own the recipe has nothing to ease — that is what `useAutoSize()` is for.",
26702
+ "example": "const size = useAutoSize<HTMLButtonElement, HTMLSpanElement>()\n<button type=\"button\" ref={size.outerRef} className=\"motion-size\">\n\t<span ref={size.innerRef} className=\"inline-flex w-max\">{copied ? \"Copied\" : \"Copy link\"}</span>\n</button>",
26703
+ "keywords": [
26704
+ "width animation",
26705
+ "auto width",
26706
+ "label change",
26707
+ "resize animation",
26708
+ "interpolate size",
26709
+ "morph button"
26710
+ ],
26711
+ "pairsWith": [
26712
+ "motion-icon-swap"
26713
+ ]
26714
+ },
24469
26715
  {
24470
26716
  "name": "motion-spin",
24471
26717
  "kind": "utility",
@@ -24951,6 +27197,7 @@
24951
27197
  "label",
24952
27198
  "legend",
24953
27199
  "locale",
27200
+ "motion",
24954
27201
  "onActiveIndexChange",
24955
27202
  "series",
24956
27203
  "stacked",
@@ -25055,6 +27302,7 @@
25055
27302
  "labels",
25056
27303
  "legend",
25057
27304
  "locale",
27305
+ "motion",
25058
27306
  "negativeTone",
25059
27307
  "onActiveIndexChange",
25060
27308
  "orientation",
@@ -25149,6 +27397,7 @@
25149
27397
  "modifiersClassNames",
25150
27398
  "modifiersStyles",
25151
27399
  "month",
27400
+ "motion",
25152
27401
  "navLayout",
25153
27402
  "nonce",
25154
27403
  "noonSafe",
@@ -25236,6 +27485,7 @@
25236
27485
  "index",
25237
27486
  "label",
25238
27487
  "loop",
27488
+ "motion",
25239
27489
  "onIndexChange",
25240
27490
  "slideClassName",
25241
27491
  "slidesToShow"
@@ -25255,6 +27505,7 @@
25255
27505
  "invalid",
25256
27506
  "label",
25257
27507
  "leading",
27508
+ "motion",
25258
27509
  "onAttach",
25259
27510
  "onSend",
25260
27511
  "onStop",
@@ -25274,6 +27525,7 @@
25274
27525
  "id",
25275
27526
  "indeterminate",
25276
27527
  "inputRef",
27528
+ "motion",
25277
27529
  "name",
25278
27530
  "nativeButton",
25279
27531
  "onCheckedChange",
@@ -25296,6 +27548,7 @@
25296
27548
  "id",
25297
27549
  "indeterminate",
25298
27550
  "inputRef",
27551
+ "motion",
25299
27552
  "name",
25300
27553
  "nativeButton",
25301
27554
  "onCheckedChange",
@@ -25556,6 +27809,21 @@
25556
27809
  "shortcut",
25557
27810
  "size"
25558
27811
  ],
27812
+ "ConfirmButton": [
27813
+ "badge",
27814
+ "bleed",
27815
+ "children",
27816
+ "confirmHint",
27817
+ "confirmLabel",
27818
+ "confirmVariant",
27819
+ "icon",
27820
+ "iconEnd",
27821
+ "motion",
27822
+ "onConfirm",
27823
+ "size",
27824
+ "timeout",
27825
+ "variant"
27826
+ ],
25559
27827
  "ContextCard": [
25560
27828
  "actionsRef",
25561
27829
  "children",
@@ -25852,10 +28120,12 @@
25852
28120
  "Feedback": [
25853
28121
  "heading",
25854
28122
  "label",
28123
+ "motion",
25855
28124
  "onSubmit",
25856
28125
  "placeholder",
25857
28126
  "ratings",
25858
- "submitLabel"
28127
+ "submitLabel",
28128
+ "successMessage"
25859
28129
  ],
25860
28130
  "Field": [
25861
28131
  "children",
@@ -26007,6 +28277,7 @@
26007
28277
  "labels",
26008
28278
  "legend",
26009
28279
  "locale",
28280
+ "motion",
26010
28281
  "onActiveIndexChange",
26011
28282
  "series",
26012
28283
  "tooltip",
@@ -26312,6 +28583,10 @@
26312
28583
  "policy",
26313
28584
  "target"
26314
28585
  ],
28586
+ "MotionScope": [
28587
+ "children",
28588
+ "level"
28589
+ ],
26315
28590
  "NavigationMenu": [
26316
28591
  "actionsRef",
26317
28592
  "align",
@@ -26368,6 +28643,7 @@
26368
28643
  "count",
26369
28644
  "dot",
26370
28645
  "max",
28646
+ "motion",
26371
28647
  "overlay",
26372
28648
  "size",
26373
28649
  "tone"
@@ -26398,6 +28674,15 @@
26398
28674
  "style",
26399
28675
  "value"
26400
28676
  ],
28677
+ "NumberTicker": [
28678
+ "format",
28679
+ "locales",
28680
+ "motion",
28681
+ "prefix",
28682
+ "suffix",
28683
+ "trend",
28684
+ "value"
28685
+ ],
26401
28686
  "OtpField": [
26402
28687
  "aria-invalid",
26403
28688
  "autoComplete",
@@ -26412,6 +28697,7 @@
26412
28697
  "inputMode",
26413
28698
  "length",
26414
28699
  "mask",
28700
+ "motion",
26415
28701
  "name",
26416
28702
  "normalizeValue",
26417
28703
  "onValueChange",
@@ -26499,6 +28785,7 @@
26499
28785
  "labels",
26500
28786
  "legend",
26501
28787
  "locale",
28788
+ "motion",
26502
28789
  "onActiveIndexChange",
26503
28790
  "separator",
26504
28791
  "tooltip",
@@ -26655,6 +28942,7 @@
26655
28942
  "label",
26656
28943
  "legend",
26657
28944
  "locale",
28945
+ "motion",
26658
28946
  "onActiveIndexChange",
26659
28947
  "radiusAxis",
26660
28948
  "series",
@@ -26675,6 +28963,7 @@
26675
28963
  "legend",
26676
28964
  "locale",
26677
28965
  "max",
28966
+ "motion",
26678
28967
  "onActiveIndexChange",
26679
28968
  "stacked",
26680
28969
  "sweep",
@@ -26999,6 +29288,7 @@
26999
29288
  "Sidebar": [
27000
29289
  "collapsed",
27001
29290
  "icons",
29291
+ "motion",
27002
29292
  "surface",
27003
29293
  "variant"
27004
29294
  ],
@@ -27016,6 +29306,7 @@
27016
29306
  ],
27017
29307
  "SideNav": [
27018
29308
  "activeVariant",
29309
+ "motion",
27019
29310
  "size"
27020
29311
  ],
27021
29312
  "SideNavGroup": [
@@ -27141,6 +29432,7 @@
27141
29432
  "data",
27142
29433
  "label",
27143
29434
  "locale",
29435
+ "motion",
27144
29436
  "tone",
27145
29437
  "variant"
27146
29438
  ],
@@ -27216,6 +29508,7 @@
27216
29508
  "Tabs": [
27217
29509
  "className",
27218
29510
  "defaultValue",
29511
+ "motion",
27219
29512
  "onValueChange",
27220
29513
  "orientation",
27221
29514
  "render",
@@ -27280,6 +29573,7 @@
27280
29573
  ],
27281
29574
  "ThemeSwitcher": [
27282
29575
  "label",
29576
+ "motion",
27283
29577
  "onValueChange",
27284
29578
  "value"
27285
29579
  ],
@@ -27357,6 +29651,7 @@
27357
29651
  "defaultValue",
27358
29652
  "disabled",
27359
29653
  "loopFocus",
29654
+ "motion",
27360
29655
  "multiple",
27361
29656
  "onValueChange",
27362
29657
  "orientation",
@@ -27420,6 +29715,19 @@
27420
29715
  "render",
27421
29716
  "style"
27422
29717
  ],
29718
+ "ToolCall": [
29719
+ "children",
29720
+ "defaultOpen",
29721
+ "error",
29722
+ "input",
29723
+ "name",
29724
+ "onOpenChange",
29725
+ "open",
29726
+ "output",
29727
+ "status",
29728
+ "statusLabel",
29729
+ "title"
29730
+ ],
27423
29731
  "Tooltip": [
27424
29732
  "actionsRef",
27425
29733
  "children",
@@ -27447,6 +29755,10 @@
27447
29755
  "sideOffset",
27448
29756
  "style"
27449
29757
  ],
29758
+ "TooltipGroup": [
29759
+ "children",
29760
+ "motion"
29761
+ ],
27450
29762
  "TooltipPortal": [
27451
29763
  "className",
27452
29764
  "container",
@@ -27471,6 +29783,9 @@
27471
29783
  "render",
27472
29784
  "style"
27473
29785
  ],
29786
+ "Tree": [
29787
+ "motion"
29788
+ ],
27474
29789
  "TreeAddRow": [
27475
29790
  "depth",
27476
29791
  "icon",
@@ -27615,19 +29930,19 @@
27615
29930
  },
27616
29931
  {
27617
29932
  "name": "MotionProvider",
27618
- "summary": "Holds the app's motion policy and writes it as `data-motion` on <html>, which drives the `--motion-scale` switch every Sable motion recipe reads. Lets an app offer its own reduce-motion setting.",
29933
+ "summary": "Holds the app's motion policy and writes it as `data-motion` on <html>, which drives the `--motion-scale` and `--motion-expressive` switches every Sable motion recipe reads. Lets an app offer its own motion setting: full, subtle (feedback without flourishes) or off.",
27619
29934
  "required": "Optional. Without it, motion follows the visitor's OS `prefers-reduced-motion` setting, and useMotion() still works (its setPolicy does nothing).",
27620
29935
  "placement": "Once, at the app root, when the app has an in-app motion setting.",
27621
29936
  "props": [
27622
29937
  {
27623
29938
  "name": "defaultPolicy",
27624
- "type": "\"system\" | \"full\" | \"reduced\" | \"off\"",
29939
+ "type": "\"system\" | \"full\" | \"subtle\" | \"reduced\" | \"off\"",
27625
29940
  "default": "\"system\"",
27626
- "description": "Initial policy when uncontrolled. \"system\" defers to the OS preference."
29941
+ "description": "Initial policy when uncontrolled. \"system\" defers to the OS preference. \"subtle\" keeps essential motion (feedback, overlays, disclosure, loaders) and drops expressive motion (glides, rolling numbers, drawn ticks, shakes, staggers, overshoot)."
27627
29942
  },
27628
29943
  {
27629
29944
  "name": "policy",
27630
- "type": "\"system\" | \"full\" | \"reduced\" | \"off\"",
29945
+ "type": "\"system\" | \"full\" | \"subtle\" | \"reduced\" | \"off\"",
27631
29946
  "description": "Controlled policy. Pair with onPolicyChange."
27632
29947
  },
27633
29948
  {
@@ -27644,13 +29959,17 @@
27644
29959
  ],
27645
29960
  "companions": [
27646
29961
  "useMotion",
27647
- "useReducedMotion"
29962
+ "useMotionLevel",
29963
+ "useReducedMotion",
29964
+ "MotionScope"
27648
29965
  ],
27649
29966
  "notes": [
27650
- "JavaScript motion must read useMotion().enabled and do nothing when it is false; CSS recipes stop on their own."
29967
+ "JavaScript motion must read useMotion().enabled and do nothing when it is false; JavaScript flourishes read useMotion().expressive, which is also false under \"subtle\". CSS recipes follow on their own.",
29968
+ "Mapping the app's \"full\" choice back to \"system\" keeps honouring the OS reduce-motion preference; \"full\" itself forces motion on."
27651
29969
  ],
27652
29970
  "related": [
27653
- "Switch"
29971
+ "Switch",
29972
+ "ToggleGroup"
27654
29973
  ],
27655
29974
  "import": "import { MotionProvider } from \"@sableui/react\"",
27656
29975
  "examples": [
@@ -27663,6 +29982,42 @@
27663
29982
  "label": "A reduce-motion setting",
27664
29983
  "code": "function ReduceMotionSetting() {\n\tconst { policy, setPolicy } = useMotion()\n\treturn (\n\t\t<Switch\n\t\t\tchecked={policy === \"reduced\"}\n\t\t\tonCheckedChange={(on) => setPolicy(on ? \"reduced\" : \"system\")}\n\t\t\taria-label=\"Reduce motion\"\n\t\t/>\n\t)\n}",
27665
29984
  "imports": "import { Switch, useMotion } from \"@sableui/react\""
29985
+ },
29986
+ {
29987
+ "label": "A three-way motion setting",
29988
+ "code": "function MotionSetting() {\n\tconst { policy, setPolicy } = useMotion()\n\treturn (\n\t\t<ToggleGroup\n\t\t\tappearance=\"segmented\"\n\t\t\tvalue={[policy === \"system\" ? \"full\" : policy]}\n\t\t\tonValueChange={(next) => next[0] && setPolicy(next[0] === \"full\" ? \"system\" : (next[0] as \"subtle\" | \"off\"))}\n\t\t\taria-label=\"Motion\"\n\t\t>\n\t\t\t<Toggle value=\"full\">Full</Toggle>\n\t\t\t<Toggle value=\"subtle\">Subtle</Toggle>\n\t\t\t<Toggle value=\"off\">Off</Toggle>\n\t\t</ToggleGroup>\n\t)\n}",
29989
+ "imports": "import { Toggle, ToggleGroup, useMotion } from \"@sableui/react\""
29990
+ }
29991
+ ]
29992
+ },
29993
+ {
29994
+ "name": "MotionScope",
29995
+ "summary": "Turns motion down for one region — a dense data view, an embedded widget, a screen a test captures — by writing `data-motion` on a box-less wrapper and passing the same level to useMotion() below it.",
29996
+ "required": "Optional. Reach for it when a whole region should be calmer than the app; for one component, use that component's `motion` prop.",
29997
+ "placement": "Around the region. It renders a `display: contents` element, so it adds no box to the layout.",
29998
+ "props": [
29999
+ {
30000
+ "name": "level",
30001
+ "type": "\"subtle\" | \"off\"",
30002
+ "description": "\"subtle\" keeps feedback, overlays, disclosure and loaders and drops glides, rolling numbers, drawn ticks, shakes and staggers. \"off\" stills everything. A scope can only turn motion down."
30003
+ }
30004
+ ],
30005
+ "companions": [
30006
+ "useMotionLevel",
30007
+ "MotionProvider"
30008
+ ],
30009
+ "notes": [
30010
+ "Overlays portal out of the DOM subtree, so their CSS follows the app-level policy; set it on MotionProvider to cover them too."
30011
+ ],
30012
+ "related": [
30013
+ "MotionProvider"
30014
+ ],
30015
+ "import": "import { MotionScope } from \"@sableui/react\"",
30016
+ "examples": [
30017
+ {
30018
+ "label": "A calm data view",
30019
+ "code": "export function Dashboard({ children }: { children: ReactNode }) {\n\treturn <MotionScope level=\"subtle\">{children}</MotionScope>\n}",
30020
+ "imports": "import type { ReactNode } from \"react\"\nimport { MotionScope } from \"@sableui/react\""
27666
30021
  }
27667
30022
  ]
27668
30023
  },