@sableui/react 0.13.2 → 0.15.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 (203) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +172 -0
  3. package/CONTRIBUTING.md +6 -4
  4. package/DESIGN.md +10 -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 +27 -1
  8. package/dist/blocks/index.js +8 -0
  9. package/dist/blocks/message-reactions-block.js +87 -0
  10. package/dist/blocks/message-reactions-block.meta.js +23 -0
  11. package/dist/components/accordion/accordion.meta.js +8 -2
  12. package/dist/components/area-chart/area-chart.meta.js +10 -3
  13. package/dist/components/attachment/attachment.meta.js +26 -0
  14. package/dist/components/bar-chart/bar-chart.meta.js +11 -4
  15. package/dist/components/bottom-nav/bottom-nav.js +3 -1
  16. package/dist/components/bottom-nav/bottom-nav.meta.js +32 -0
  17. package/dist/components/breadcrumbs/breadcrumbs.meta.js +26 -0
  18. package/dist/components/bubble/bubble.js +105 -5
  19. package/dist/components/bubble/bubble.meta.js +42 -6
  20. package/dist/components/bubble/bubble.variants.js +1 -1
  21. package/dist/components/bubble/index.js +2 -0
  22. package/dist/components/button/button.js +21 -4
  23. package/dist/components/button/button.meta.js +7 -7
  24. package/dist/components/calendar/calendar.js +18 -1
  25. package/dist/components/calendar/calendar.meta.js +38 -0
  26. package/dist/components/carousel/carousel.js +6 -4
  27. package/dist/components/carousel/carousel.meta.js +42 -0
  28. package/dist/components/chat-composer/chat-composer.js +21 -21
  29. package/dist/components/chat-composer/chat-composer.meta.js +38 -0
  30. package/dist/components/chat-composer/use-eased-growth.js +70 -0
  31. package/dist/components/checkbox/checkbox-mark.js +8 -1
  32. package/dist/components/checkbox/checkbox.js +3 -1
  33. package/dist/components/checkbox/checkbox.meta.js +32 -0
  34. package/dist/components/checkbox-card/checkbox-card.js +3 -0
  35. package/dist/components/checkbox-card/checkbox-card.meta.js +32 -0
  36. package/dist/components/code-block/code-block.js +14 -3
  37. package/dist/components/code-block/code-block.meta.js +28 -1
  38. package/dist/components/collapsible/collapsible.meta.js +32 -0
  39. package/dist/components/color-field/color-field.meta.js +26 -0
  40. package/dist/components/combobox/combobox.meta.js +44 -0
  41. package/dist/components/command-menu/command-menu.js +8 -3
  42. package/dist/components/command-menu/command-menu.meta.js +38 -0
  43. package/dist/components/confirm-button/confirm-button.js +179 -0
  44. package/dist/components/confirm-button/confirm-button.meta.js +143 -0
  45. package/dist/components/confirm-button/index.js +7 -0
  46. package/dist/components/data-table/data-table.js +6 -2
  47. package/dist/components/data-table/data-table.meta.js +32 -0
  48. package/dist/components/entity/entity.meta.js +26 -0
  49. package/dist/components/feedback/feedback.js +120 -47
  50. package/dist/components/feedback/feedback.meta.js +55 -4
  51. package/dist/components/input-group/input-group.meta.js +27 -1
  52. package/dist/components/like-button/index.js +7 -0
  53. package/dist/components/like-button/like-button.js +89 -0
  54. package/dist/components/like-button/like-button.meta.js +132 -0
  55. package/dist/components/line-chart/line-chart.meta.js +10 -3
  56. package/dist/components/middle-truncate/middle-truncate.meta.js +1 -1
  57. package/dist/components/note/note.meta.js +32 -0
  58. package/dist/components/notification-badge/notification-badge.js +17 -5
  59. package/dist/components/notification-badge/notification-badge.meta.js +34 -2
  60. package/dist/components/number-ticker/index.js +7 -0
  61. package/dist/components/number-ticker/number-ticker.js +246 -0
  62. package/dist/components/number-ticker/number-ticker.meta.js +159 -0
  63. package/dist/components/otp-field/otp-field.js +23 -3
  64. package/dist/components/otp-field/otp-field.meta.js +23 -2
  65. package/dist/components/panel-group/panel-group.meta.js +26 -0
  66. package/dist/components/panel-section/panel-section.js +37 -10
  67. package/dist/components/panel-section/panel-section.meta.js +32 -0
  68. package/dist/components/pie-chart/pie-chart.js +20 -11
  69. package/dist/components/pie-chart/pie-chart.meta.js +17 -4
  70. package/dist/components/pill/pill.meta.js +32 -0
  71. package/dist/components/radar-chart/radar-chart.js +4 -3
  72. package/dist/components/radar-chart/radar-chart.meta.js +10 -3
  73. package/dist/components/radial-chart/radial-chart.js +22 -22
  74. package/dist/components/radial-chart/radial-chart.meta.js +17 -4
  75. package/dist/components/radio/radio.meta.js +26 -0
  76. package/dist/components/radio-card/radio-card.meta.js +38 -0
  77. package/dist/components/reaction-picker/index.js +13 -0
  78. package/dist/components/reaction-picker/reaction-picker.js +104 -0
  79. package/dist/components/reaction-picker/reaction-picker.meta.js +156 -0
  80. package/dist/components/relative-time/relative-time.meta.js +32 -0
  81. package/dist/components/scroller/scroller.meta.js +32 -0
  82. package/dist/components/scrub-field/scrub-field.meta.js +32 -0
  83. package/dist/components/search-input/search-input.js +9 -7
  84. package/dist/components/search-input/search-input.meta.js +26 -0
  85. package/dist/components/show-more/show-more.js +1 -4
  86. package/dist/components/show-more/show-more.meta.js +38 -0
  87. package/dist/components/side-nav/side-nav.js +21 -1
  88. package/dist/components/side-nav/side-nav.meta.js +32 -0
  89. package/dist/components/side-nav/side-nav.variants.js +3 -1
  90. package/dist/components/sidebar/sidebar.js +31 -8
  91. package/dist/components/sidebar/sidebar.meta.js +38 -0
  92. package/dist/components/slider/slider.meta.js +26 -0
  93. package/dist/components/sparkline/sparkline.js +7 -4
  94. package/dist/components/sparkline/sparkline.meta.js +9 -2
  95. package/dist/components/table/table.meta.js +26 -0
  96. package/dist/components/tabs/tabs.js +27 -8
  97. package/dist/components/tabs/tabs.meta.js +46 -3
  98. package/dist/components/theme-selector/theme-selector.meta.js +32 -0
  99. package/dist/components/theme-switcher/theme-switcher.js +4 -1
  100. package/dist/components/theme-switcher/theme-switcher.meta.js +42 -0
  101. package/dist/components/toast/toast.js +25 -13
  102. package/dist/components/toast/toast.meta.js +7 -1
  103. package/dist/components/toggle/toggle.js +28 -5
  104. package/dist/components/toggle/toggle.meta.js +44 -2
  105. package/dist/components/tool-call/index.js +8 -0
  106. package/dist/components/tool-call/tool-call.js +117 -0
  107. package/dist/components/tool-call/tool-call.meta.js +147 -0
  108. package/dist/components/tool-call/tool-call.variants.js +33 -0
  109. package/dist/components/tooltip/index.js +3 -1
  110. package/dist/components/tooltip/tooltip.js +84 -0
  111. package/dist/components/tooltip/tooltip.meta.js +16 -1
  112. package/dist/components/tree/tree.js +71 -6
  113. package/dist/components/tree/tree.meta.js +32 -0
  114. package/dist/components/video/video.meta.js +32 -0
  115. package/dist/file-hashes.json +130 -104
  116. package/dist/foundations/motion.js +69 -3
  117. package/dist/icon-sets/reicon.js +11 -1
  118. package/dist/index.d.ts +907 -104
  119. package/dist/index.js +6 -1
  120. package/dist/lib/chart/cartesian.js +9 -10
  121. package/dist/lib/chart/chart-center.js +30 -0
  122. package/dist/lib/chart/chart-frame.js +15 -8
  123. package/dist/lib/chart/dim.js +13 -0
  124. package/dist/lib/chart/format.js +7 -1
  125. package/dist/lib/chart/motion.js +6 -4
  126. package/dist/lib/highlight-glide.js +68 -10
  127. package/dist/lib/taxonomy.js +5 -0
  128. package/dist/motion/Burst.js +79 -0
  129. package/dist/motion/MotionProvider.js +45 -3
  130. package/dist/motion/Reveal.js +1 -1
  131. package/dist/motion/TextReveal.js +1 -1
  132. package/dist/motion/index.js +17 -2
  133. package/dist/motion/useAutoSize.js +42 -0
  134. package/dist/motion/useShake.js +25 -0
  135. package/dist/styles.css +439 -5
  136. package/llms/blocks/approval-block.md +128 -0
  137. package/llms/blocks/message-reactions-block.md +116 -0
  138. package/llms/components/accordion.md +3 -2
  139. package/llms/components/area-chart.md +4 -3
  140. package/llms/components/attachment.md +7 -0
  141. package/llms/components/bar-chart.md +5 -4
  142. package/llms/components/bottom-nav.md +8 -0
  143. package/llms/components/breadcrumbs.md +7 -0
  144. package/llms/components/bubble.md +17 -7
  145. package/llms/components/button.md +6 -6
  146. package/llms/components/calendar.md +9 -0
  147. package/llms/components/carousel.md +17 -0
  148. package/llms/components/chat-composer.md +9 -0
  149. package/llms/components/checkbox-card.md +8 -0
  150. package/llms/components/checkbox.md +8 -0
  151. package/llms/components/code-block.md +8 -0
  152. package/llms/components/collapsible.md +8 -0
  153. package/llms/components/color-field.md +7 -0
  154. package/llms/components/combobox.md +10 -0
  155. package/llms/components/command-menu.md +9 -0
  156. package/llms/components/confirm-button.md +92 -0
  157. package/llms/components/data-table.md +8 -0
  158. package/llms/components/entity.md +7 -0
  159. package/llms/components/feedback.md +23 -3
  160. package/llms/components/input-group.md +8 -1
  161. package/llms/components/like-button.md +91 -0
  162. package/llms/components/line-chart.md +4 -3
  163. package/llms/components/middle-truncate.md +1 -1
  164. package/llms/components/note.md +8 -0
  165. package/llms/components/notification-badge.md +10 -2
  166. package/llms/components/number-ticker.md +86 -0
  167. package/llms/components/otp-field.md +15 -1
  168. package/llms/components/panel-group.md +7 -0
  169. package/llms/components/panel-section.md +8 -0
  170. package/llms/components/pie-chart.md +6 -4
  171. package/llms/components/pill.md +8 -0
  172. package/llms/components/radar-chart.md +4 -3
  173. package/llms/components/radial-chart.md +6 -4
  174. package/llms/components/radio-card.md +9 -0
  175. package/llms/components/radio.md +7 -0
  176. package/llms/components/reaction-picker.md +115 -0
  177. package/llms/components/relative-time.md +8 -0
  178. package/llms/components/scroller.md +8 -0
  179. package/llms/components/scrub-field.md +8 -0
  180. package/llms/components/search-input.md +7 -0
  181. package/llms/components/show-more.md +9 -0
  182. package/llms/components/side-nav.md +8 -0
  183. package/llms/components/sidebar.md +9 -0
  184. package/llms/components/slider.md +7 -0
  185. package/llms/components/sparkline.md +3 -2
  186. package/llms/components/table.md +7 -0
  187. package/llms/components/tabs.md +20 -2
  188. package/llms/components/theme-selector.md +8 -0
  189. package/llms/components/theme-switcher.md +17 -0
  190. package/llms/components/toast.md +2 -1
  191. package/llms/components/toggle.md +19 -2
  192. package/llms/components/tool-call.md +115 -0
  193. package/llms/components/tooltip.md +11 -1
  194. package/llms/components/tree.md +8 -0
  195. package/llms/components/video.md +8 -0
  196. package/llms/foundations/motion.md +64 -3
  197. package/llms/reference/utilities.md +26 -1
  198. package/llms/tasks/reviewing-motion.md +14 -0
  199. package/llms-full.txt +1337 -69
  200. package/llms.txt +19 -11
  201. package/package.json +1 -1
  202. package/registry.json +3040 -124
  203. package/targets/ios.json +45 -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.15.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,13 @@
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-bloom",
958
+ "motion-size",
952
959
  "motion-reveal",
953
960
  "motion-draw",
954
961
  "motion-spin",
@@ -966,6 +973,7 @@
966
973
  ],
967
974
  "policy": [
968
975
  "data-motion=full",
976
+ "data-motion=subtle",
969
977
  "data-motion=reduced",
970
978
  "data-motion=off",
971
979
  "prefers-reduced-motion"
@@ -1778,7 +1786,10 @@
1778
1786
  "paperclip",
1779
1787
  "chart",
1780
1788
  "flag",
1781
- "at-sign"
1789
+ "at-sign",
1790
+ "replay",
1791
+ "motion",
1792
+ "emoji"
1782
1793
  ]
1783
1794
  },
1784
1795
  "layoutPrimitives": {
@@ -2809,6 +2820,12 @@
2809
2820
  "part": "AccordionPanel",
2810
2821
  "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
2822
  },
2823
+ {
2824
+ "slot": "transition",
2825
+ "recipe": "motion-interactive",
2826
+ "part": "Chevron",
2827
+ "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."
2828
+ },
2812
2829
  {
2813
2830
  "slot": "activity",
2814
2831
  "recipe": "none",
@@ -2821,7 +2838,7 @@
2821
2838
  "Arrow keys move roving focus between triggers; Home/End jump to the first/last; loopFocus wraps by default.",
2822
2839
  "Each trigger toggles its panel with Enter/Space; the panel is announced as a labelled region.",
2823
2840
  "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."
2841
+ "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
2842
  ],
2826
2843
  "examples": [
2827
2844
  {
@@ -2836,7 +2853,7 @@
2836
2853
  },
2837
2854
  {
2838
2855
  "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>",
2856
+ "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
2857
  "imports": "import { Accordion, AccordionItem, AccordionPanel, AccordionTrigger, Icon } from \"@sableui/react\""
2841
2858
  }
2842
2859
  ]
@@ -3214,11 +3231,18 @@
3214
3231
  "default": "false for columns; true for horizontal bars",
3215
3232
  "description": "The vertical axis."
3216
3233
  },
3234
+ {
3235
+ "name": "motion",
3236
+ "type": "\"full\" | \"subtle\" | \"off\"",
3237
+ "default": "\"full\"",
3238
+ "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."
3239
+ },
3217
3240
  {
3218
3241
  "name": "animate",
3219
3242
  "type": "boolean",
3220
3243
  "default": "true",
3221
- "description": "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart."
3244
+ "description": "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart. `animate={false}` is `motion=\"subtle\"`.",
3245
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to: no arrival, hover kept) or motion=\"off\""
3222
3246
  },
3223
3247
  {
3224
3248
  "name": "activeIndex",
@@ -3273,7 +3297,7 @@
3273
3297
  "slot": "arrival",
3274
3298
  "recipe": "custom",
3275
3299
  "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."
3300
+ "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
3301
  },
3278
3302
  {
3279
3303
  "slot": "response",
@@ -3285,7 +3309,7 @@
3285
3309
  "slot": "transition",
3286
3310
  "recipe": "custom",
3287
3311
  "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."
3312
+ "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
3313
  },
3290
3314
  {
3291
3315
  "slot": "activity",
@@ -3476,6 +3500,32 @@
3476
3500
  "overlay-foreground",
3477
3501
  "ring"
3478
3502
  ],
3503
+ "motion": [
3504
+ {
3505
+ "slot": "arrival",
3506
+ "recipe": "none",
3507
+ "part": "Root",
3508
+ "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."
3509
+ },
3510
+ {
3511
+ "slot": "response",
3512
+ "recipe": "motion-interactive",
3513
+ "part": "Root",
3514
+ "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."
3515
+ },
3516
+ {
3517
+ "slot": "transition",
3518
+ "recipe": "motion-interactive",
3519
+ "part": "Root",
3520
+ "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."
3521
+ },
3522
+ {
3523
+ "slot": "activity",
3524
+ "recipe": "motion-pulse",
3525
+ "part": "Progress",
3526
+ "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."
3527
+ }
3528
+ ],
3479
3529
  "a11y": [
3480
3530
  "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
3531
  "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 +4390,18 @@
4340
4390
  "default": "false for columns; true for horizontal bars",
4341
4391
  "description": "The vertical axis."
4342
4392
  },
4393
+ {
4394
+ "name": "motion",
4395
+ "type": "\"full\" | \"subtle\" | \"off\"",
4396
+ "default": "\"full\"",
4397
+ "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."
4398
+ },
4343
4399
  {
4344
4400
  "name": "animate",
4345
4401
  "type": "boolean",
4346
4402
  "default": "true",
4347
- "description": "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart."
4403
+ "description": "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart. `animate={false}` is `motion=\"subtle\"`.",
4404
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to: no arrival, hover kept) or motion=\"off\""
4348
4405
  },
4349
4406
  {
4350
4407
  "name": "activeIndex",
@@ -4422,19 +4479,19 @@
4422
4479
  "slot": "arrival",
4423
4480
  "recipe": "custom",
4424
4481
  "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."
4482
+ "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
4483
  },
4427
4484
  {
4428
4485
  "slot": "response",
4429
4486
  "recipe": "none",
4430
4487
  "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."
4488
+ "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
4489
  },
4433
4490
  {
4434
4491
  "slot": "transition",
4435
4492
  "recipe": "custom",
4436
4493
  "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."
4494
+ "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
4495
  },
4439
4496
  {
4440
4497
  "slot": "activity",
@@ -4534,6 +4591,38 @@
4534
4591
  "foreground",
4535
4592
  "ring"
4536
4593
  ],
4594
+ "motion": [
4595
+ {
4596
+ "slot": "arrival",
4597
+ "recipe": "none",
4598
+ "part": "Root",
4599
+ "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."
4600
+ },
4601
+ {
4602
+ "slot": "response",
4603
+ "recipe": "motion-interactive",
4604
+ "part": "BottomNavItem",
4605
+ "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."
4606
+ },
4607
+ {
4608
+ "slot": "response",
4609
+ "recipe": "motion-press",
4610
+ "part": "BottomNavItem",
4611
+ "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."
4612
+ },
4613
+ {
4614
+ "slot": "transition",
4615
+ "recipe": "motion-interactive",
4616
+ "part": "BottomNavItem",
4617
+ "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."
4618
+ },
4619
+ {
4620
+ "slot": "activity",
4621
+ "recipe": "none",
4622
+ "part": "Root",
4623
+ "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."
4624
+ }
4625
+ ],
4537
4626
  "a11y": [
4538
4627
  "BottomNav renders a <nav> landmark.",
4539
4628
  "Each tab is a 44px minimum touch target with an aria-label.",
@@ -4648,6 +4737,32 @@
4648
4737
  "muted-foreground",
4649
4738
  "foreground"
4650
4739
  ],
4740
+ "motion": [
4741
+ {
4742
+ "slot": "arrival",
4743
+ "recipe": "none",
4744
+ "part": "Root",
4745
+ "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."
4746
+ },
4747
+ {
4748
+ "slot": "response",
4749
+ "recipe": "motion-interactive",
4750
+ "part": "BreadcrumbLink",
4751
+ "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."
4752
+ },
4753
+ {
4754
+ "slot": "transition",
4755
+ "recipe": "none",
4756
+ "part": "Root",
4757
+ "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."
4758
+ },
4759
+ {
4760
+ "slot": "activity",
4761
+ "recipe": "none",
4762
+ "part": "Root",
4763
+ "why": "It never works in the background. A route that is still loading says so in the page, not in its address."
4764
+ }
4765
+ ],
4651
4766
  "a11y": [
4652
4767
  "The <nav> carries aria-label=\"Breadcrumb\" so assistive tech announces the landmark.",
4653
4768
  "Crumbs sit in an ordered list (<ol>) to convey hierarchy and position.",
@@ -4813,7 +4928,12 @@
4813
4928
  {
4814
4929
  "name": "BubbleReaction",
4815
4930
  "required": false,
4816
- "description": "One reaction pill — emoji, count (hidden at 1), and pressed state."
4931
+ "description": "One reaction pill — emoji, count (hidden at 1), and pressed state. Celebrates when it becomes the reader's own."
4932
+ },
4933
+ {
4934
+ "name": "BubbleReactionAdd",
4935
+ "required": false,
4936
+ "description": "The add-reaction pill at the end of the row: opens a ReactionPicker above it."
4817
4937
  }
4818
4938
  ]
4819
4939
  },
@@ -4822,7 +4942,8 @@
4822
4942
  "Message",
4823
4943
  "MessageScroller",
4824
4944
  "Marker",
4825
- "Attachment"
4945
+ "Attachment",
4946
+ "ReactionPicker"
4826
4947
  ]
4827
4948
  },
4828
4949
  "props": [
@@ -4858,8 +4979,13 @@
4858
4979
  },
4859
4980
  {
4860
4981
  "name": "BubbleReaction",
4861
- "type": "{ emoji: ReactNode; count?: number; active?: boolean } & ComponentPropsWithRef<\"button\">",
4862
- "description": "One reaction pill. A toggle, not a label: `active` drives `aria-pressed` as well as the styling, and `count` is hidden at 1 because the pill already says \"one\"."
4982
+ "type": "{ emoji: ReactNode; label?: string; count?: number; active?: boolean; motion?: MotionLevel } & ComponentPropsWithRef<\"button\">",
4983
+ "description": "One reaction pill. A toggle, not a label: `active` drives `aria-pressed` as well as the styling, `label` names it (\"Party, 3\"), and `count` is hidden at 1 because the pill already says \"one\". When it turns on — pressed, or newly added already on — the emoji blooms, copies of it burst out and the count rolls; pills the page loads with stay still."
4984
+ },
4985
+ {
4986
+ "name": "BubbleReactionAdd",
4987
+ "type": "{ reactions?: Reaction[]; selected?: string[]; onSelect?: (emoji: string) => void; label?: string; motion?: MotionLevel } & ComponentPropsWithRef<\"button\">",
4988
+ "description": "The add-reaction pill: a smiley that opens a ReactionPicker above it. Choosing an emoji closes the picker and calls `onSelect`; add the emoji to the row and its new pill celebrates on arrival."
4863
4989
  }
4864
4990
  ],
4865
4991
  "variants": {
@@ -4891,9 +5017,35 @@
4891
5017
  "muted-foreground",
4892
5018
  "ring"
4893
5019
  ],
5020
+ "motion": [
5021
+ {
5022
+ "slot": "arrival",
5023
+ "recipe": "none",
5024
+ "part": "Bubble",
5025
+ "why": "A bubble arrives with its Message row, which owns how a new message enters the transcript. A reaction pill that the reader just added arrives with its own celebration (below)."
5026
+ },
5027
+ {
5028
+ "slot": "response",
5029
+ "recipe": "motion-bloom",
5030
+ "part": "BubbleReaction",
5031
+ "why": "Reacting is the reader's choice, so it is answered like a like: the emoji grows from nothing past its size and settles, while copies of it spray outward and fade (Burst with the emoji as its particle, the way chat apps answer a reaction). Plays when a pill turns on and when a pill mounts already on into a row that has rendered — never for the pills a page loads with. Pills inherit Pill's motion-interactive and motion-press."
5032
+ },
5033
+ {
5034
+ "slot": "transition",
5035
+ "recipe": "custom",
5036
+ "part": "BubbleReaction",
5037
+ "why": "The count rolls with NumberTicker as people react. Turning a reaction off is quiet: the tint eases out and the count rolls back, with no burst. Under `subtle` or reduced motion the bloom and the spray are dropped and the tint and count alone answer."
5038
+ },
5039
+ {
5040
+ "slot": "activity",
5041
+ "recipe": "none",
5042
+ "part": "Bubble",
5043
+ "why": "A bubble is static content; a message still being written is StreamingText or a Marker, not a state of the surface."
5044
+ }
5045
+ ],
4894
5046
  "a11y": [
4895
5047
  "The surface is a plain container with no imposed role — a bubble is presentation, and announcing one as a region would put a landmark around every line of a conversation.",
4896
- "Reaction pills are real buttons reporting `aria-pressed`, so the reader's own reaction is state rather than colour alone; the emoji is hidden from AT and the count is left readable.",
5048
+ "Reaction pills are real buttons reporting `aria-pressed`, so the reader's own reaction is state rather than colour alone. Give each a `label`: it is named \"Party, 3\", with the emoji and its burst hidden from AT. The add-reaction pill is named \"Add reaction\" and opens a toolbar picker.",
4897
5049
  "`whitespace-pre-wrap` and `break-words` keep pasted logs, long URLs, and hard-wrapped text inside the surface instead of overflowing the conversation.",
4898
5050
  "Every variant meets AA against its own surface, including `primary`, which pairs the primary fill with its paired foreground token rather than a hardcoded white."
4899
5051
  ],
@@ -4910,8 +5062,8 @@
4910
5062
  },
4911
5063
  {
4912
5064
  "label": "With reactions",
4913
- "code": "<Message messageId=\"m3\">\n\t<Bubble>Shipped 0.45.0.</Bubble>\n\t<BubbleReactions>\n\t\t<BubbleReaction emoji=\"🎉\" count={3} active onClick={() => toggle(\"🎉\")} />\n\t\t<BubbleReaction emoji=\"👀\" count={1} onClick={() => toggle(\"👀\")} />\n\t</BubbleReactions>\n</Message>",
4914
- "imports": "import { Bubble, BubbleReaction, BubbleReactions, Message } from \"@sableui/react\""
5065
+ "code": "<Message messageId=\"m3\">\n\t<Bubble>Shipped 0.45.0.</Bubble>\n\t<BubbleReactions>\n\t\t<BubbleReaction emoji=\"🎉\" label=\"Party\" count={3} active onClick={() => toggle(\"🎉\")} />\n\t\t<BubbleReaction emoji=\"👀\" label=\"Eyes\" count={1} onClick={() => toggle(\"👀\")} />\n\t\t<BubbleReactionAdd selected={mine} onSelect={toggle} />\n\t</BubbleReactions>\n</Message>",
5066
+ "imports": "import { Bubble, BubbleReaction, BubbleReactionAdd, BubbleReactions, Message } from \"@sableui/react\""
4915
5067
  },
4916
5068
  {
4917
5069
  "label": "A grouped run drops the repeated tail",
@@ -4958,7 +5110,7 @@
4958
5110
  },
4959
5111
  {
4960
5112
  "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."
5113
+ "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
5114
  },
4963
5115
  {
4964
5116
  "guidance": "do",
@@ -5059,7 +5211,7 @@
5059
5211
  "name": "loading",
5060
5212
  "type": "boolean",
5061
5213
  "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."
5214
+ "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
5215
  },
5064
5216
  {
5065
5217
  "name": "onClick",
@@ -5074,7 +5226,7 @@
5074
5226
  {
5075
5227
  "name": "loadingLabel",
5076
5228
  "type": "ReactNode",
5077
- "description": "Optional text shown in place of children while loading."
5229
+ "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
5230
  },
5079
5231
  {
5080
5232
  "name": "iconStart",
@@ -5152,9 +5304,9 @@
5152
5304
  },
5153
5305
  {
5154
5306
  "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."
5307
+ "recipe": "motion-appear",
5308
+ "part": "Spinner",
5309
+ "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
5310
  },
5159
5311
  {
5160
5312
  "slot": "activity",
@@ -5195,8 +5347,8 @@
5195
5347
  },
5196
5348
  {
5197
5349
  "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\""
5350
+ "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>",
5351
+ "imports": "import { Button, Icon, Row, useCopy } from \"@sableui/react\""
5200
5352
  },
5201
5353
  {
5202
5354
  "label": "As link",
@@ -5435,6 +5587,12 @@
5435
5587
  "default": "false",
5436
5588
  "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
5589
  },
5590
+ {
5591
+ "name": "motion",
5592
+ "type": "\"full\" | \"subtle\" | \"off\"",
5593
+ "default": "\"full\"",
5594
+ "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."
5595
+ },
5438
5596
  {
5439
5597
  "name": "className",
5440
5598
  "type": "string",
@@ -5469,6 +5627,38 @@
5469
5627
  "muted-foreground",
5470
5628
  "ring"
5471
5629
  ],
5630
+ "motion": [
5631
+ {
5632
+ "slot": "arrival",
5633
+ "recipe": "none",
5634
+ "part": "Root",
5635
+ "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."
5636
+ },
5637
+ {
5638
+ "slot": "response",
5639
+ "recipe": "motion-interactive",
5640
+ "part": "Day",
5641
+ "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."
5642
+ },
5643
+ {
5644
+ "slot": "transition",
5645
+ "recipe": "motion-interactive",
5646
+ "part": "Day",
5647
+ "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."
5648
+ },
5649
+ {
5650
+ "slot": "activity",
5651
+ "recipe": "none",
5652
+ "part": "Root",
5653
+ "why": "Days are resident data; nothing in the grid loads. Availability fetched per month belongs to the surface around the calendar."
5654
+ },
5655
+ {
5656
+ "slot": "transition",
5657
+ "recipe": "custom",
5658
+ "part": "Month",
5659
+ "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."
5660
+ }
5661
+ ],
5472
5662
  "a11y": [
5473
5663
  "react-day-picker manages the roving-tabindex grid, arrow-key navigation, and ARIA roles/labels for the calendar.",
5474
5664
  "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 +6182,12 @@
5992
6182
  "name": "arrowClassName",
5993
6183
  "type": "string",
5994
6184
  "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."
6185
+ },
6186
+ {
6187
+ "name": "motion",
6188
+ "type": "\"full\" | \"subtle\" | \"off\"",
6189
+ "default": "\"full\"",
6190
+ "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
6191
  }
5996
6192
  ],
5997
6193
  "tokensUsed": [
@@ -6002,6 +6198,38 @@
6002
6198
  "foreground",
6003
6199
  "muted-foreground"
6004
6200
  ],
6201
+ "motion": [
6202
+ {
6203
+ "slot": "arrival",
6204
+ "recipe": "none",
6205
+ "part": "Root",
6206
+ "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."
6207
+ },
6208
+ {
6209
+ "slot": "response",
6210
+ "recipe": "motion-interactive",
6211
+ "part": "Dot",
6212
+ "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."
6213
+ },
6214
+ {
6215
+ "slot": "transition",
6216
+ "recipe": "custom",
6217
+ "part": "Track",
6218
+ "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."
6219
+ },
6220
+ {
6221
+ "slot": "transition",
6222
+ "recipe": "motion-glide",
6223
+ "part": "Dot",
6224
+ "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."
6225
+ },
6226
+ {
6227
+ "slot": "activity",
6228
+ "recipe": "none",
6229
+ "part": "Root",
6230
+ "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."
6231
+ }
6232
+ ],
6005
6233
  "a11y": [
6006
6234
  "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
6235
  "Arrows and dots are real buttons (dots set aria-current); Left/Right arrow keys navigate when the viewport is focused.",
@@ -6028,6 +6256,11 @@
6028
6256
  "label": "Autoplay",
6029
6257
  "code": "<Carousel loop autoplay={4000} counter>{slides}</Carousel>",
6030
6258
  "imports": "import { Carousel } from \"@sableui/react\""
6259
+ },
6260
+ {
6261
+ "label": "Subtle motion",
6262
+ "code": "<Carousel loop motion=\"subtle\" label=\"Gallery\">{slides}</Carousel>",
6263
+ "imports": "import { Carousel } from \"@sableui/react\""
6031
6264
  }
6032
6265
  ]
6033
6266
  },
@@ -6246,6 +6479,12 @@
6246
6479
  "type": "Ref<HTMLTextAreaElement>",
6247
6480
  "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
6481
  },
6482
+ {
6483
+ "name": "motion",
6484
+ "type": "\"full\" | \"subtle\" | \"off\"",
6485
+ "default": "\"full\"",
6486
+ "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."
6487
+ },
6249
6488
  {
6250
6489
  "name": "...form props",
6251
6490
  "type": "Omit<ComponentPropsWithRef<\"form\">, \"onSubmit\" | \"defaultValue\">",
@@ -6272,6 +6511,38 @@
6272
6511
  "accent",
6273
6512
  "accent-foreground"
6274
6513
  ],
6514
+ "motion": [
6515
+ {
6516
+ "slot": "arrival",
6517
+ "recipe": "none",
6518
+ "part": "Root",
6519
+ "why": "The composer is the fixed foot of a thread, there before anything else is. An entrance would only delay the first keystroke."
6520
+ },
6521
+ {
6522
+ "slot": "response",
6523
+ "recipe": "motion-interactive",
6524
+ "part": "Root",
6525
+ "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."
6526
+ },
6527
+ {
6528
+ "slot": "transition",
6529
+ "recipe": "custom",
6530
+ "part": "Field",
6531
+ "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."
6532
+ },
6533
+ {
6534
+ "slot": "transition",
6535
+ "recipe": "motion-icon-swap",
6536
+ "part": "Send button",
6537
+ "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."
6538
+ },
6539
+ {
6540
+ "slot": "activity",
6541
+ "recipe": "motion-spin",
6542
+ "part": "Send button",
6543
+ "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."
6544
+ }
6545
+ ],
6275
6546
  "a11y": [
6276
6547
  "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
6548
  "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 +6677,12 @@
6406
6677
  "name": "render",
6407
6678
  "type": "ReactElement | (props, state) => ReactElement",
6408
6679
  "description": "Compose the root with another element through the Base UI render prop."
6680
+ },
6681
+ {
6682
+ "name": "motion",
6683
+ "type": "\"full\" | \"subtle\" | \"off\"",
6684
+ "default": "\"full\"",
6685
+ "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
6686
  }
6410
6687
  ],
6411
6688
  "tokensUsed": [
@@ -6415,6 +6692,32 @@
6415
6692
  "primary-foreground",
6416
6693
  "ring"
6417
6694
  ],
6695
+ "motion": [
6696
+ {
6697
+ "slot": "arrival",
6698
+ "recipe": "none",
6699
+ "part": "Root",
6700
+ "why": "It arrives with the form it belongs to. Animating each box in turns a settings list into a title sequence."
6701
+ },
6702
+ {
6703
+ "slot": "response",
6704
+ "recipe": "motion-interactive",
6705
+ "part": "Box",
6706
+ "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."
6707
+ },
6708
+ {
6709
+ "slot": "transition",
6710
+ "recipe": "motion-tick",
6711
+ "part": "Mark",
6712
+ "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."
6713
+ },
6714
+ {
6715
+ "slot": "activity",
6716
+ "recipe": "none",
6717
+ "part": "Root",
6718
+ "why": "A checkbox commits instantly. A slow write belongs to the surrounding form's pending state, not the control."
6719
+ }
6720
+ ],
6418
6721
  "a11y": [
6419
6722
  "Base UI renders a hidden native <input type=checkbox> for keyboard, focus, and form semantics.",
6420
6723
  "Space toggles; focus-visible draws a 2px ring in the --ring token.",
@@ -6545,6 +6848,12 @@
6545
6848
  "name": "checked / onCheckedChange / defaultChecked / disabled",
6546
6849
  "type": "Base UI Checkbox.Root props",
6547
6850
  "description": "Standard checkbox state. Without a group-owned value, each card is independent — drive checked/onCheckedChange per item for multi-select."
6851
+ },
6852
+ {
6853
+ "name": "motion",
6854
+ "type": "\"full\" | \"subtle\" | \"off\"",
6855
+ "default": "\"full\"",
6856
+ "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
6857
  }
6549
6858
  ],
6550
6859
  "subcomponents": [
@@ -6584,6 +6893,32 @@
6584
6893
  "tone-warning",
6585
6894
  "tone-warning-foreground"
6586
6895
  ],
6896
+ "motion": [
6897
+ {
6898
+ "slot": "arrival",
6899
+ "recipe": "none",
6900
+ "part": "Root",
6901
+ "why": "It arrives with the form it belongs to. Animating each box in turns a settings list into a title sequence."
6902
+ },
6903
+ {
6904
+ "slot": "response",
6905
+ "recipe": "motion-interactive",
6906
+ "part": "Box",
6907
+ "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."
6908
+ },
6909
+ {
6910
+ "slot": "transition",
6911
+ "recipe": "motion-tick",
6912
+ "part": "Mark",
6913
+ "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."
6914
+ },
6915
+ {
6916
+ "slot": "activity",
6917
+ "recipe": "none",
6918
+ "part": "Root",
6919
+ "why": "A checkbox commits instantly. A slow write belongs to the surrounding form's pending state, not the control."
6920
+ }
6921
+ ],
6587
6922
  "a11y": [
6588
6923
  "Base UI provides the checkbox role, aria-checked, hidden native input, keyboard toggle, and data-[checked]/data-disabled state.",
6589
6924
  "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 +7112,36 @@
6777
7112
  "muted-foreground",
6778
7113
  "foreground"
6779
7114
  ],
7115
+ "motion": [
7116
+ {
7117
+ "slot": "arrival",
7118
+ "recipe": "none",
7119
+ "part": "Root",
7120
+ "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."
7121
+ },
7122
+ {
7123
+ "slot": "response",
7124
+ "recipe": "motion-interactive",
7125
+ "part": "Copy button",
7126
+ "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."
7127
+ },
7128
+ {
7129
+ "slot": "transition",
7130
+ "recipe": "motion-icon-swap",
7131
+ "part": "Copy glyph",
7132
+ "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."
7133
+ },
7134
+ {
7135
+ "slot": "activity",
7136
+ "recipe": "none",
7137
+ "part": "Root",
7138
+ "why": "Copying is instant. There is no in-between worth showing, and a spinner for a clipboard write would read as a slow one."
7139
+ }
7140
+ ],
6780
7141
  "a11y": [
6781
7142
  "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."
7143
+ "Copy is the ghost icon Button with `useCopy`, which guards clipboard access for SSR safety.",
7144
+ "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
7145
  ],
6784
7146
  "examples": [
6785
7147
  {
@@ -6901,21 +7263,53 @@
6901
7263
  "foreground",
6902
7264
  "muted-foreground"
6903
7265
  ],
6904
- "a11y": [
6905
- "Base UI wires aria-expanded on the trigger and aria-controls/id between trigger and panel.",
6906
- "Trigger is a real <button>; toggles on click and Enter/Space.",
6907
- "Animate open/close via data-[starting-style]/data-[ending-style]; data-[panel-open] marks the open state.",
6908
- "Set hiddenUntilFound on the panel to let in-page browser search expand it."
6909
- ],
6910
- "examples": [
7266
+ "motion": [
6911
7267
  {
6912
- "label": "Basic",
6913
- "code": "<Collapsible><CollapsibleTrigger render={<Button variant=\"ghost\">Details</Button>} /><CollapsiblePanel><p className=\"pt-3\">Hidden content revealed on toggle.</p></CollapsiblePanel></Collapsible>",
6914
- "imports": "import { Button, Collapsible, CollapsiblePanel, CollapsibleTrigger } from \"@sableui/react\""
7268
+ "slot": "arrival",
7269
+ "recipe": "none",
7270
+ "part": "Root",
7271
+ "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."
6915
7272
  },
6916
7273
  {
6917
- "label": "Open by default",
6918
- "code": "<Collapsible defaultOpen><CollapsibleTrigger render={<Button variant=\"ghost\">Summary</Button>} /><CollapsiblePanel><p className=\"pt-3\">Visible on first render.</p></CollapsiblePanel></Collapsible>",
7274
+ "slot": "response",
7275
+ "recipe": "none",
7276
+ "part": "CollapsibleTrigger",
7277
+ "why": "The trigger is whatever the consumer renders (usually a Button), and that control brings its own response. Adding one here would double it."
7278
+ },
7279
+ {
7280
+ "slot": "transition",
7281
+ "recipe": "motion-disclosure",
7282
+ "part": "CollapsiblePanel",
7283
+ "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."
7284
+ },
7285
+ {
7286
+ "slot": "transition",
7287
+ "recipe": "motion-interactive",
7288
+ "part": "CollapsibleIndicator",
7289
+ "why": "The chevron turns with the panel rather than flipping in one frame: motion-interactive lists `rotate`, the property rotate-180 writes."
7290
+ },
7291
+ {
7292
+ "slot": "activity",
7293
+ "recipe": "none",
7294
+ "part": "Root",
7295
+ "why": "Nothing here is indeterminate; content that loads on open should put its own loader inside the panel."
7296
+ }
7297
+ ],
7298
+ "a11y": [
7299
+ "Base UI wires aria-expanded on the trigger and aria-controls/id between trigger and panel.",
7300
+ "Trigger is a real <button>; toggles on click and Enter/Space.",
7301
+ "Animate open/close via data-[starting-style]/data-[ending-style]; data-[panel-open] marks the open state.",
7302
+ "Set hiddenUntilFound on the panel to let in-page browser search expand it."
7303
+ ],
7304
+ "examples": [
7305
+ {
7306
+ "label": "Basic",
7307
+ "code": "<Collapsible><CollapsibleTrigger render={<Button variant=\"ghost\">Details</Button>} /><CollapsiblePanel><p className=\"pt-3\">Hidden content revealed on toggle.</p></CollapsiblePanel></Collapsible>",
7308
+ "imports": "import { Button, Collapsible, CollapsiblePanel, CollapsibleTrigger } from \"@sableui/react\""
7309
+ },
7310
+ {
7311
+ "label": "Open by default",
7312
+ "code": "<Collapsible defaultOpen><CollapsibleTrigger render={<Button variant=\"ghost\">Summary</Button>} /><CollapsiblePanel><p className=\"pt-3\">Visible on first render.</p></CollapsiblePanel></Collapsible>",
6919
7313
  "imports": "import { Button, Collapsible, CollapsiblePanel, CollapsibleTrigger } from \"@sableui/react\""
6920
7314
  },
6921
7315
  {
@@ -7042,6 +7436,32 @@
7042
7436
  "ring",
7043
7437
  "popover"
7044
7438
  ],
7439
+ "motion": [
7440
+ {
7441
+ "slot": "arrival",
7442
+ "recipe": "motion-pop",
7443
+ "part": "Popover",
7444
+ "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."
7445
+ },
7446
+ {
7447
+ "slot": "response",
7448
+ "recipe": "motion-interactive",
7449
+ "part": "Swatch",
7450
+ "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."
7451
+ },
7452
+ {
7453
+ "slot": "transition",
7454
+ "recipe": "none",
7455
+ "part": "Handles",
7456
+ "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."
7457
+ },
7458
+ {
7459
+ "slot": "activity",
7460
+ "recipe": "none",
7461
+ "part": "Root",
7462
+ "why": "Picking is synchronous. The EyeDropper is the browser's own modal, with its own feedback."
7463
+ }
7464
+ ],
7045
7465
  "a11y": [
7046
7466
  "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
7467
  "The Transparent entry is a toggle with aria-pressed, because it is a state of the value and not a navigation action.",
@@ -7288,6 +7708,50 @@
7288
7708
  "secondary",
7289
7709
  "secondary-foreground"
7290
7710
  ],
7711
+ "motion": [
7712
+ {
7713
+ "slot": "arrival",
7714
+ "recipe": "motion-pop",
7715
+ "part": "ComboboxContent",
7716
+ "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."
7717
+ },
7718
+ {
7719
+ "slot": "response",
7720
+ "recipe": "motion-interactive",
7721
+ "part": "ComboboxItem",
7722
+ "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."
7723
+ },
7724
+ {
7725
+ "slot": "response",
7726
+ "recipe": "custom",
7727
+ "part": "Highlight",
7728
+ "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."
7729
+ },
7730
+ {
7731
+ "slot": "response",
7732
+ "recipe": "motion-interactive",
7733
+ "part": "Toggle",
7734
+ "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."
7735
+ },
7736
+ {
7737
+ "slot": "transition",
7738
+ "recipe": "motion-interactive",
7739
+ "part": "Toggle",
7740
+ "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."
7741
+ },
7742
+ {
7743
+ "slot": "transition",
7744
+ "recipe": "motion-pop",
7745
+ "part": "ComboboxContent",
7746
+ "why": "Closing shrinks back toward the field, returning the eye to the input that now holds the value."
7747
+ },
7748
+ {
7749
+ "slot": "activity",
7750
+ "recipe": "none",
7751
+ "part": "Root",
7752
+ "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."
7753
+ }
7754
+ ],
7291
7755
  "a11y": [
7292
7756
  "Base UI provides role=combobox/listbox/option, roving tabindex, arrow-key navigation, and type-to-filter.",
7293
7757
  "Esc and outside-press dismiss; focus returns to the input on close.",
@@ -7582,6 +8046,44 @@
7582
8046
  "accent",
7583
8047
  "accent-foreground"
7584
8048
  ],
8049
+ "motion": [
8050
+ {
8051
+ "slot": "arrival",
8052
+ "recipe": "motion-pop",
8053
+ "part": "Dialog",
8054
+ "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."
8055
+ },
8056
+ {
8057
+ "slot": "arrival",
8058
+ "recipe": "motion-appear",
8059
+ "part": "Clear button",
8060
+ "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."
8061
+ },
8062
+ {
8063
+ "slot": "response",
8064
+ "recipe": "motion-interactive",
8065
+ "part": "CommandItem",
8066
+ "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."
8067
+ },
8068
+ {
8069
+ "slot": "response",
8070
+ "recipe": "motion-interactive",
8071
+ "part": "CommandMenuTrigger",
8072
+ "why": "The search-shaped trigger eases its ink on hover like the field shell it imitates, so it reads as the field it opens."
8073
+ },
8074
+ {
8075
+ "slot": "transition",
8076
+ "recipe": "motion-pop",
8077
+ "part": "Dialog",
8078
+ "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."
8079
+ },
8080
+ {
8081
+ "slot": "activity",
8082
+ "recipe": "none",
8083
+ "part": "List",
8084
+ "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."
8085
+ }
8086
+ ],
7585
8087
  "a11y": [
7586
8088
  "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
8089
  "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 +8140,223 @@
7638
8140
  }
7639
8141
  ]
7640
8142
  },
8143
+ {
8144
+ "name": "ConfirmButton",
8145
+ "status": "stable",
8146
+ "group": "Actions",
8147
+ "register": "either",
8148
+ "path": "components/confirm-button",
8149
+ "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.",
8150
+ "keywords": [
8151
+ "confirm",
8152
+ "confirmation",
8153
+ "two-step",
8154
+ "press again",
8155
+ "click again",
8156
+ "arm",
8157
+ "delete",
8158
+ "remove",
8159
+ "destructive",
8160
+ "inline confirm",
8161
+ "hold to confirm"
8162
+ ],
8163
+ "usage": {
8164
+ "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.",
8165
+ "bestPractices": [
8166
+ {
8167
+ "guidance": "do",
8168
+ "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."
8169
+ },
8170
+ {
8171
+ "guidance": "do",
8172
+ "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."
8173
+ },
8174
+ {
8175
+ "guidance": "do",
8176
+ "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."
8177
+ },
8178
+ {
8179
+ "guidance": "dont",
8180
+ "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.",
8181
+ "instead": [
8182
+ "DestructiveActionModal"
8183
+ ]
8184
+ },
8185
+ {
8186
+ "guidance": "dont",
8187
+ "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.",
8188
+ "instead": [
8189
+ "Toast"
8190
+ ]
8191
+ },
8192
+ {
8193
+ "guidance": "dont",
8194
+ "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.",
8195
+ "instead": [
8196
+ "Button"
8197
+ ]
8198
+ }
8199
+ ]
8200
+ },
8201
+ "related": {
8202
+ "components": [
8203
+ "Button",
8204
+ "DestructiveActionModal",
8205
+ "Toast",
8206
+ "OverflowMenu"
8207
+ ]
8208
+ },
8209
+ "props": [
8210
+ {
8211
+ "name": "children",
8212
+ "type": "ReactNode",
8213
+ "description": "The idle label — the action, e.g. \"Delete\"."
8214
+ },
8215
+ {
8216
+ "name": "confirmLabel",
8217
+ "type": "ReactNode",
8218
+ "default": "\"Confirm <label>?\"",
8219
+ "description": "The label while armed. Defaults to \"Confirm delete?\" for a text label \"Delete\" (first letter lowered) and \"Confirm?\" otherwise. Pass it to localise."
8220
+ },
8221
+ {
8222
+ "name": "onConfirm",
8223
+ "type": "() => unknown",
8224
+ "description": "Runs on the second press while armed. Return a promise to hold the button armed with a spinner until it settles."
8225
+ },
8226
+ {
8227
+ "name": "timeout",
8228
+ "type": "number",
8229
+ "default": "3000",
8230
+ "description": "How long it waits for the second press, in ms. A decision time, so it is not scaled by motion settings."
8231
+ },
8232
+ {
8233
+ "name": "variant",
8234
+ "type": "ButtonVariant",
8235
+ "default": "\"secondary\"",
8236
+ "description": "The idle look — any Button variant."
8237
+ },
8238
+ {
8239
+ "name": "confirmVariant",
8240
+ "type": "ButtonVariant",
8241
+ "default": "\"destructive\"",
8242
+ "description": "The armed look — any Button variant."
8243
+ },
8244
+ {
8245
+ "name": "size",
8246
+ "type": "\"xs\" | \"sm\" | \"md\" | \"cta\"",
8247
+ "default": "\"md\"",
8248
+ "description": "A labelled Button size. Icon-only sizes are excluded: the label is the interaction."
8249
+ },
8250
+ {
8251
+ "name": "icon",
8252
+ "type": "ReactNode",
8253
+ "description": "A leading icon kept through both states, e.g. `<Icon name=\"trash\" />`."
8254
+ },
8255
+ {
8256
+ "name": "disabled",
8257
+ "type": "boolean",
8258
+ "default": "false",
8259
+ "description": "Disables it; disabling while armed also disarms."
8260
+ },
8261
+ {
8262
+ "name": "confirmHint",
8263
+ "type": "string",
8264
+ "default": "\"Press again to confirm.\"",
8265
+ "description": "Read to assistive tech after the armed label when it arms. Override to localise."
8266
+ },
8267
+ {
8268
+ "name": "motion",
8269
+ "type": "\"full\" | \"subtle\" | \"off\"",
8270
+ "default": "\"full\"",
8271
+ "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."
8272
+ }
8273
+ ],
8274
+ "variants": {
8275
+ "variant": [
8276
+ "primary",
8277
+ "secondary",
8278
+ "ghost",
8279
+ "outline",
8280
+ "destructive",
8281
+ "link",
8282
+ "floating",
8283
+ "field",
8284
+ "on-color",
8285
+ "on-color-outline",
8286
+ "on-color-ghost"
8287
+ ],
8288
+ "confirmVariant": [
8289
+ "destructive",
8290
+ "primary",
8291
+ "secondary",
8292
+ "outline"
8293
+ ],
8294
+ "size": [
8295
+ "xs",
8296
+ "sm",
8297
+ "md",
8298
+ "cta"
8299
+ ]
8300
+ },
8301
+ "tokensUsed": [
8302
+ "secondary",
8303
+ "secondary-foreground",
8304
+ "destructive",
8305
+ "destructive-foreground",
8306
+ "ring"
8307
+ ],
8308
+ "motion": [
8309
+ {
8310
+ "slot": "arrival",
8311
+ "recipe": "none",
8312
+ "part": "Root",
8313
+ "why": "It is a button in a row of actions, and arrives with that row. Its moment is the arming, not the mount."
8314
+ },
8315
+ {
8316
+ "slot": "response",
8317
+ "recipe": "motion-press",
8318
+ "part": "Root",
8319
+ "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."
8320
+ },
8321
+ {
8322
+ "slot": "transition",
8323
+ "recipe": "motion-size",
8324
+ "part": "Root",
8325
+ "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."
8326
+ },
8327
+ {
8328
+ "slot": "activity",
8329
+ "recipe": "custom",
8330
+ "part": "Countdown",
8331
+ "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."
8332
+ }
8333
+ ],
8334
+ "a11y": [
8335
+ "A native <button> through Button. Focus stays on it through both presses; Enter and Space confirm exactly as a click does.",
8336
+ "Only the current label is in the accessible name — the other is aria-hidden — so the name changes to the question while armed.",
8337
+ "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.",
8338
+ "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.",
8339
+ "A second press within 300ms of arming is ignored: a double-click is a habit, not a decision.",
8340
+ "Under reduced motion the label still changes and the colour still turns; only the width easing and countdown are dropped."
8341
+ ],
8342
+ "examples": [
8343
+ {
8344
+ "label": "Delete a row",
8345
+ "code": "<ConfirmButton icon={<Icon name=\"trash\" />} onConfirm={() => removeRow(id)}>Delete</ConfirmButton>",
8346
+ "imports": "import { ConfirmButton, Icon } from \"@sableui/react\""
8347
+ },
8348
+ {
8349
+ "label": "Custom question, longer wait",
8350
+ "code": "<ConfirmButton variant=\"ghost\" size=\"sm\" confirmLabel=\"Revoke this key?\" timeout={5000} onConfirm={revokeKey}>Revoke</ConfirmButton>",
8351
+ "imports": "import { ConfirmButton } from \"@sableui/react\""
8352
+ },
8353
+ {
8354
+ "label": "Async confirm",
8355
+ "code": "<ConfirmButton onConfirm={() => api.deleteDraft(draft.id)}>Discard draft</ConfirmButton>",
8356
+ "imports": "import { ConfirmButton } from \"@sableui/react\""
8357
+ }
8358
+ ]
8359
+ },
7641
8360
  {
7642
8361
  "name": "ContextCard",
7643
8362
  "status": "stable",
@@ -8042,6 +8761,38 @@
8042
8761
  "foreground",
8043
8762
  "ring"
8044
8763
  ],
8764
+ "motion": [
8765
+ {
8766
+ "slot": "arrival",
8767
+ "recipe": "none",
8768
+ "part": "Root",
8769
+ "why": "Rows render with the page or the query that produced them. Staggering rows in delays reading a table someone opened to scan."
8770
+ },
8771
+ {
8772
+ "slot": "response",
8773
+ "recipe": "motion-interactive",
8774
+ "part": "Row",
8775
+ "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."
8776
+ },
8777
+ {
8778
+ "slot": "transition",
8779
+ "recipe": "motion-interactive",
8780
+ "part": "Sort chevron",
8781
+ "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."
8782
+ },
8783
+ {
8784
+ "slot": "transition",
8785
+ "recipe": "motion-interactive",
8786
+ "part": "Row",
8787
+ "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."
8788
+ },
8789
+ {
8790
+ "slot": "activity",
8791
+ "recipe": "none",
8792
+ "part": "Root",
8793
+ "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."
8794
+ }
8795
+ ],
8045
8796
  "a11y": [
8046
8797
  "Sorted columns set aria-sort=ascending/descending on the header cell.",
8047
8798
  "Sortable headers are real <button>s with a focus-visible ring, keyboard-operable.",
@@ -9304,6 +10055,32 @@
9304
10055
  "muted-foreground",
9305
10056
  "ring"
9306
10057
  ],
10058
+ "motion": [
10059
+ {
10060
+ "slot": "arrival",
10061
+ "recipe": "none",
10062
+ "part": "Root",
10063
+ "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."
10064
+ },
10065
+ {
10066
+ "slot": "response",
10067
+ "recipe": "motion-interactive",
10068
+ "part": "Root",
10069
+ "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."
10070
+ },
10071
+ {
10072
+ "slot": "transition",
10073
+ "recipe": "motion-interactive",
10074
+ "part": "Root",
10075
+ "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."
10076
+ },
10077
+ {
10078
+ "slot": "activity",
10079
+ "recipe": "none",
10080
+ "part": "Root",
10081
+ "why": "A record row has nothing of its own to wait for. A row whose action is pending puts a loading Button in `action`."
10082
+ }
10083
+ ],
9307
10084
  "a11y": [
9308
10085
  "Default rows are presentational layout; semantics come from the content you place in title/subtitle/action.",
9309
10086
  "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 +10107,7 @@
9330
10107
  "group": "Overlays",
9331
10108
  "register": "page",
9332
10109
  "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.",
10110
+ "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
10111
  "keywords": [
9335
10112
  "feedback",
9336
10113
  "sentiment",
@@ -9399,7 +10176,19 @@
9399
10176
  {
9400
10177
  "name": "onSubmit",
9401
10178
  "type": "(data: { rating?: string; message: string }) => void",
9402
- "description": "Called with the selected rating + message when the form is submitted."
10179
+ "description": "Called with the selected rating + message when the form is submitted. The thanks shows right away; send the report in the background."
10180
+ },
10181
+ {
10182
+ "name": "successMessage",
10183
+ "type": "string",
10184
+ "default": "\"Thanks for the feedback.\"",
10185
+ "description": "The line shown, and read out, for about 1.2s after sending, before the popover closes on its own."
10186
+ },
10187
+ {
10188
+ "name": "motion",
10189
+ "type": "\"full\" | \"subtle\" | \"off\"",
10190
+ "default": "\"full\"",
10191
+ "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
10192
  }
9404
10193
  ],
9405
10194
  "tokensUsed": [
@@ -9412,14 +10201,49 @@
9412
10201
  "muted-foreground",
9413
10202
  "accent",
9414
10203
  "accent-foreground",
9415
- "ring"
10204
+ "ring",
10205
+ "success"
10206
+ ],
10207
+ "motion": [
10208
+ {
10209
+ "slot": "arrival",
10210
+ "recipe": "motion-pop",
10211
+ "part": "Popover",
10212
+ "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."
10213
+ },
10214
+ {
10215
+ "slot": "response",
10216
+ "recipe": "motion-interactive",
10217
+ "part": "Rating toggles",
10218
+ "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."
10219
+ },
10220
+ {
10221
+ "slot": "response",
10222
+ "recipe": "motion-bloom",
10223
+ "part": "Chosen rating mark",
10224
+ "why": "The mark itself answers the pick: it blooms past its size and settles, so choosing reads as a small decision landing rather than a fill changing. Only the chosen mark, only on choosing — nothing blooms when the form opens or a pick is let go. Expressive: under subtle the fill alone answers."
10225
+ },
10226
+ {
10227
+ "slot": "transition",
10228
+ "recipe": "motion-tick",
10229
+ "part": "Thanks",
10230
+ "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."
10231
+ },
10232
+ {
10233
+ "slot": "activity",
10234
+ "recipe": "none",
10235
+ "part": "Root",
10236
+ "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."
10237
+ }
9416
10238
  ],
9417
10239
  "a11y": [
9418
10240
  "Trigger opens the popover on press; Base UI manages focus into the form and restores it to the trigger on close.",
9419
10241
  "The rating row is a Base UI ToggleGroup with arrow-key roving focus; each toggle carries an aria-label.",
9420
10242
  "The form is named by its heading (aria-labelledby), so it is announced as \"Send feedback\" rather than an unnamed form.",
9421
10243
  "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."
10244
+ "Submit is disabled until there is a rating or a non-whitespace message. Either alone is a complete answer.",
10245
+ "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.",
10246
+ "The thanks dwell (about 1.2s) is a reading time, not motion, so it is the same under reduced motion."
9423
10247
  ],
9424
10248
  "examples": [
9425
10249
  {
@@ -9431,6 +10255,11 @@
9431
10255
  "label": "Custom labels",
9432
10256
  "code": "<Feedback label=\"Tell us\" submitLabel=\"Send it\" placeholder=\"Describe the issue…\" />",
9433
10257
  "imports": "import { Feedback } from \"@sableui/react\""
10258
+ },
10259
+ {
10260
+ "label": "Custom thanks",
10261
+ "code": "<Feedback successMessage=\"Got it — thank you.\" onSubmit={({ rating, message }) => report(rating, message)} />",
10262
+ "imports": "import { Feedback } from \"@sableui/react\""
9434
10263
  }
9435
10264
  ]
9436
10265
  },
@@ -10832,6 +11661,32 @@
10832
11661
  "muted-foreground",
10833
11662
  "foreground"
10834
11663
  ],
11664
+ "motion": [
11665
+ {
11666
+ "slot": "arrival",
11667
+ "recipe": "none",
11668
+ "part": "Root",
11669
+ "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."
11670
+ },
11671
+ {
11672
+ "slot": "response",
11673
+ "recipe": "motion-interactive",
11674
+ "part": "Root",
11675
+ "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."
11676
+ },
11677
+ {
11678
+ "slot": "transition",
11679
+ "recipe": "motion-interactive",
11680
+ "part": "Root",
11681
+ "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."
11682
+ },
11683
+ {
11684
+ "slot": "activity",
11685
+ "recipe": "none",
11686
+ "part": "Root",
11687
+ "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."
11688
+ }
11689
+ ],
10835
11690
  "a11y": [
10836
11691
  "The Input stays the focusable control; the group draws the focus ring around the whole field while it is focused.",
10837
11692
  "Inside a Field, the label, description and error are wired to the Input itself, not the group.",
@@ -10851,7 +11706,7 @@
10851
11706
  },
10852
11707
  {
10853
11708
  "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>",
11709
+ "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
11710
  "imports": "import { Button, Field, Icon, Input, InputGroup } from \"@sableui/react\""
10856
11711
  },
10857
11712
  {
@@ -11008,66 +11863,277 @@
11008
11863
  },
11009
11864
  "props": [
11010
11865
  {
11011
- "name": "htmlFor",
11866
+ "name": "htmlFor",
11867
+ "type": "string",
11868
+ "description": "Associates the label with a control by its id (native <label for>)."
11869
+ },
11870
+ {
11871
+ "name": "size",
11872
+ "type": "\"xs\" | \"sm\" | \"md\" | \"lg\"",
11873
+ "default": "\"lg\"",
11874
+ "description": "Paired label typography step, all sentence case. xs is simply the smallest; for an uppercase eyebrow use eyebrow."
11875
+ },
11876
+ {
11877
+ "name": "eyebrow",
11878
+ "type": "boolean",
11879
+ "default": "false",
11880
+ "description": "Set the label as an eyebrow: the uppercase, letter-spaced text-overline tier (themeable to sentence case). Replaces size. For a kicker above a group, not an ordinary field label."
11881
+ },
11882
+ {
11883
+ "name": "hint",
11884
+ "type": "ReactNode",
11885
+ "description": "When set, the label gets a dotted-underline affordance and reveals this content in a tooltip on hover/focus."
11886
+ },
11887
+ {
11888
+ "name": "className",
11889
+ "type": "string",
11890
+ "description": "Merge or override classes via cn()."
11891
+ }
11892
+ ],
11893
+ "variants": {
11894
+ "size": [
11895
+ "xs",
11896
+ "sm",
11897
+ "md",
11898
+ "lg"
11899
+ ]
11900
+ },
11901
+ "tokensUsed": [
11902
+ "foreground",
11903
+ "muted-foreground"
11904
+ ],
11905
+ "a11y": [
11906
+ "Renders a native <label> — clicking it focuses/activates the associated control.",
11907
+ "Associate with a control via htmlFor (matching the control's id) or by wrapping the control.",
11908
+ "In a form, use Field: it renders this Label, wires htmlFor/id, and dims the label when the field is disabled. Hand-wired outside a Field, the label only dims via peer-disabled when the control carries `peer` AND precedes the Label in the DOM (Tailwind peer-* only matches a later sibling).",
11909
+ "With `hint`, the label becomes focusable and the help text is announced via the tooltip's aria-describedby — so the affordance works for keyboard and screen-reader users, not just hover."
11910
+ ],
11911
+ "examples": [
11912
+ {
11913
+ "label": "With control",
11914
+ "code": "<Stack gap={1}><Label htmlFor=\"name\">Display name</Label><Input id=\"name\" /></Stack>",
11915
+ "imports": "import { Input, Label, Stack } from \"@sableui/react\""
11916
+ },
11917
+ {
11918
+ "label": "In a form",
11919
+ "code": "<Field label=\"Display name\" disabled><Input /></Field>",
11920
+ "imports": "import { Field, Input } from \"@sableui/react\""
11921
+ },
11922
+ {
11923
+ "label": "With hint",
11924
+ "code": "<Label htmlFor=\"discount\" hint=\"Taken off every order this code is used on.\">Discount</Label>",
11925
+ "imports": "import { Label } from \"@sableui/react\""
11926
+ }
11927
+ ]
11928
+ },
11929
+ {
11930
+ "name": "LikeButton",
11931
+ "status": "stable",
11932
+ "group": "Actions",
11933
+ "register": "either",
11934
+ "path": "components/like-button",
11935
+ "summary": "A pressed-or-not toggle for a like, a star or a save, with its count. Pressing it on fills the glyph, which blooms while a ring and a spray of dots burst from it and the count rolls up; pressing it off is quiet.",
11936
+ "keywords": [
11937
+ "like",
11938
+ "heart",
11939
+ "favorite",
11940
+ "favourite",
11941
+ "love",
11942
+ "star",
11943
+ "bookmark",
11944
+ "save",
11945
+ "upvote",
11946
+ "reaction",
11947
+ "count",
11948
+ "toggle"
11949
+ ],
11950
+ "usage": {
11951
+ "description": "Use LikeButton for a personal, reversible endorsement that other people can see the tally of: liking a post, starring a repository, saving a recipe. It is the one place in Sable that celebrates, because the person chose it and it costs nothing to undo.",
11952
+ "bestPractices": [
11953
+ {
11954
+ "guidance": "do",
11955
+ "description": "Pass the server's total as `count` and update it when the save lands. The button shows the viewer's own like at once, before the save, and goes back to `count` exactly whenever it changes, so a failed save that leaves `count` alone can also turn `pressed` back off."
11956
+ },
11957
+ {
11958
+ "guidance": "do",
11959
+ "description": "Match the glyph, family and label to the meaning: `icon=\"star\" tone=\"yellow\" label=\"Star\"` for a star. The label is the accessible name, so it must say what pressing does."
11960
+ },
11961
+ {
11962
+ "guidance": "do",
11963
+ "description": "Use `format={{ notation: \"compact\" }}` once counts pass a few thousand, so a feed of buttons keeps one width."
11964
+ },
11965
+ {
11966
+ "guidance": "dont",
11967
+ "description": "Do not use it for a setting or a view switch (bold, mute, grid or list). Those are tools, not endorsements, and a burst on every press of a formatting control is noise; use Toggle.",
11968
+ "instead": [
11969
+ "Toggle"
11970
+ ]
11971
+ },
11972
+ {
11973
+ "guidance": "dont",
11974
+ "description": "Do not use it for a rating or a verdict on an answer (helpful, not helpful). That is a question with two sides; use Feedback.",
11975
+ "instead": [
11976
+ "Feedback"
11977
+ ]
11978
+ },
11979
+ {
11980
+ "guidance": "dont",
11981
+ "description": "Do not press it for the user. A burst is a reply to a person's choice — liking something on their behalf, or on load, turns delight into a notification."
11982
+ }
11983
+ ]
11984
+ },
11985
+ "related": {
11986
+ "components": [
11987
+ "Toggle",
11988
+ "Feedback",
11989
+ "NumberTicker",
11990
+ "Button"
11991
+ ]
11992
+ },
11993
+ "props": [
11994
+ {
11995
+ "name": "count",
11996
+ "type": "number",
11997
+ "description": "The total, rolled when it changes. Pressing adds or removes the viewer's like at once; the button shows `count` exactly again whenever `count` changes. Omit it for a bare glyph (an icon-size button)."
11998
+ },
11999
+ {
12000
+ "name": "pressed",
12001
+ "type": "boolean",
12002
+ "description": "Controlled pressed state."
12003
+ },
12004
+ {
12005
+ "name": "defaultPressed",
12006
+ "type": "boolean",
12007
+ "default": "false",
12008
+ "description": "Initial pressed state when uncontrolled."
12009
+ },
12010
+ {
12011
+ "name": "onPressedChange",
12012
+ "type": "(pressed: boolean, event) => void",
12013
+ "description": "Called when the user presses it, with the new state."
12014
+ },
12015
+ {
12016
+ "name": "icon",
12017
+ "type": "string",
12018
+ "default": "\"heart\"",
12019
+ "description": "The glyph, by icon name. Its `solid` variant is shown while pressed."
12020
+ },
12021
+ {
12022
+ "name": "tone",
12023
+ "type": "PaletteColor",
12024
+ "default": "\"red\"",
12025
+ "description": "The palette family the glyph fills with while pressed, and the burst blooms from. A family, so it follows the theme."
12026
+ },
12027
+ {
12028
+ "name": "label",
11012
12029
  "type": "string",
11013
- "description": "Associates the label with a control by its id (native <label for>)."
12030
+ "default": "\"Like\"",
12031
+ "description": "The accessible name, read before the count."
12032
+ },
12033
+ {
12034
+ "name": "format",
12035
+ "type": "Intl.NumberFormatOptions",
12036
+ "description": "How to write the count, e.g. `{ notation: \"compact\" }`."
12037
+ },
12038
+ {
12039
+ "name": "variant",
12040
+ "type": "\"ghost\" | \"outline\" | \"secondary\"",
12041
+ "default": "\"ghost\"",
12042
+ "description": "The Button surface."
11014
12043
  },
11015
12044
  {
11016
12045
  "name": "size",
11017
- "type": "\"xs\" | \"sm\" | \"md\" | \"lg\"",
11018
- "default": "\"lg\"",
11019
- "description": "Paired label typography step, all sentence case. xs is simply the smallest; for an uppercase eyebrow use eyebrow."
12046
+ "type": "\"xs\" | \"sm\" | \"md\"",
12047
+ "default": "\"sm\"",
12048
+ "description": "A Button size; without a count, the matching icon size."
11020
12049
  },
11021
12050
  {
11022
- "name": "eyebrow",
12051
+ "name": "disabled",
11023
12052
  "type": "boolean",
11024
12053
  "default": "false",
11025
- "description": "Set the label as an eyebrow: the uppercase, letter-spaced text-overline tier (themeable to sentence case). Replaces size. For a kicker above a group, not an ordinary field label."
12054
+ "description": "Disables it."
11026
12055
  },
11027
12056
  {
11028
- "name": "hint",
11029
- "type": "ReactNode",
11030
- "description": "When set, the label gets a dotted-underline affordance and reveals this content in a tooltip on hover/focus."
11031
- },
11032
- {
11033
- "name": "className",
11034
- "type": "string",
11035
- "description": "Merge or override classes via cn()."
12057
+ "name": "motion",
12058
+ "type": "\"full\" | \"subtle\" | \"off\"",
12059
+ "default": "\"full\"",
12060
+ "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 bloom and the burst are the flourish; the fill and the count changing stay."
11036
12061
  }
11037
12062
  ],
11038
12063
  "variants": {
12064
+ "variant": [
12065
+ "ghost",
12066
+ "outline",
12067
+ "secondary"
12068
+ ],
11039
12069
  "size": [
11040
12070
  "xs",
11041
12071
  "sm",
11042
- "md",
11043
- "lg"
12072
+ "md"
12073
+ ],
12074
+ "tone": [
12075
+ "red",
12076
+ "orange",
12077
+ "yellow",
12078
+ "green",
12079
+ "teal",
12080
+ "blue",
12081
+ "purple",
12082
+ "pink"
11044
12083
  ]
11045
12084
  },
11046
12085
  "tokensUsed": [
11047
- "foreground",
11048
- "muted-foreground"
12086
+ "palette-dot",
12087
+ "accent",
12088
+ "ring"
12089
+ ],
12090
+ "motion": [
12091
+ {
12092
+ "slot": "arrival",
12093
+ "recipe": "none",
12094
+ "part": "Root",
12095
+ "why": "It arrives with the post or row it belongs to. A page that loads with likes already given must be still: nothing blooms on mount."
12096
+ },
12097
+ {
12098
+ "slot": "response",
12099
+ "recipe": "motion-bloom",
12100
+ "part": "Glyph",
12101
+ "why": "Pressing it on is the moment. The glyph is 16px, so it needs real amplitude to read as an event: it grows from nothing past its size and settles, after a 90ms beat in which the ring leads. Inherits Button's motion-press for the press itself. Pressing off has no bloom — taking a like back is not a celebration."
12102
+ },
12103
+ {
12104
+ "slot": "transition",
12105
+ "recipe": "custom",
12106
+ "part": "Burst",
12107
+ "why": "The follow-through: <Burst> draws a disc that hollows into a ring and shifts colour, then dots thrown outward in the theme's palette families. Expressive only — under `subtle` or reduced motion it renders nothing and the fill alone answers the press. The count rolls with NumberTicker."
12108
+ },
12109
+ {
12110
+ "slot": "activity",
12111
+ "recipe": "none",
12112
+ "part": "Root",
12113
+ "why": "There is no in-between state to show: the like is shown at once, before it is saved."
12114
+ }
11049
12115
  ],
11050
12116
  "a11y": [
11051
- "Renders a native <label> — clicking it focuses/activates the associated control.",
11052
- "Associate with a control via htmlFor (matching the control's id) or by wrapping the control.",
11053
- "In a form, use Field: it renders this Label, wires htmlFor/id, and dims the label when the field is disabled. Hand-wired outside a Field, the label only dims via peer-disabled when the control carries `peer` AND precedes the Label in the DOM (Tailwind peer-* only matches a later sibling).",
11054
- "With `hint`, the label becomes focusable and the help text is announced via the tooltip's aria-describedby — so the affordance works for keyboard and screen-reader users, not just hover."
12117
+ "A native <button> through Button, with `aria-pressed`. Enter and Space press it.",
12118
+ "The accessible name is the label then the count (\"Like 1,301\"); the burst is aria-hidden and the rolling digits are hidden behind the count's real text.",
12119
+ "The pressed state is carried by the filled glyph and `aria-pressed`, never by colour alone.",
12120
+ "Under reduced motion the glyph fills and the count changes at once; the bloom and the burst are dropped."
11055
12121
  ],
11056
12122
  "examples": [
11057
12123
  {
11058
- "label": "With control",
11059
- "code": "<Stack gap={1}><Label htmlFor=\"name\">Display name</Label><Input id=\"name\" /></Stack>",
11060
- "imports": "import { Input, Label, Stack } from \"@sableui/react\""
12124
+ "label": "A post's likes",
12125
+ "code": "<LikeButton count={post.likes} defaultPressed={post.liked} onPressedChange={(on) => api.like(post.id, on)} />",
12126
+ "imports": "import { LikeButton } from \"@sableui/react\""
11061
12127
  },
11062
12128
  {
11063
- "label": "In a form",
11064
- "code": "<Field label=\"Display name\" disabled><Input /></Field>",
11065
- "imports": "import { Field, Input } from \"@sableui/react\""
12129
+ "label": "Star a repository",
12130
+ "code": "<LikeButton icon=\"star\" tone=\"yellow\" label=\"Star\" variant=\"outline\" count={repo.stars} format={{ notation: \"compact\" }} />",
12131
+ "imports": "import { LikeButton } from \"@sableui/react\""
11066
12132
  },
11067
12133
  {
11068
- "label": "With hint",
11069
- "code": "<Label htmlFor=\"discount\" hint=\"Taken off every order this code is used on.\">Discount</Label>",
11070
- "imports": "import { Label } from \"@sableui/react\""
12134
+ "label": "Bare glyph",
12135
+ "code": "<LikeButton label=\"Save recipe\" />",
12136
+ "imports": "import { LikeButton } from \"@sableui/react\""
11071
12137
  }
11072
12138
  ]
11073
12139
  },
@@ -11190,11 +12256,18 @@
11190
12256
  "default": "false for columns; true for horizontal bars",
11191
12257
  "description": "The vertical axis."
11192
12258
  },
12259
+ {
12260
+ "name": "motion",
12261
+ "type": "\"full\" | \"subtle\" | \"off\"",
12262
+ "default": "\"full\"",
12263
+ "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."
12264
+ },
11193
12265
  {
11194
12266
  "name": "animate",
11195
12267
  "type": "boolean",
11196
12268
  "default": "true",
11197
- "description": "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart."
12269
+ "description": "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart. `animate={false}` is `motion=\"subtle\"`.",
12270
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to: no arrival, hover kept) or motion=\"off\""
11198
12271
  },
11199
12272
  {
11200
12273
  "name": "activeIndex",
@@ -11255,7 +12328,7 @@
11255
12328
  "slot": "arrival",
11256
12329
  "recipe": "custom",
11257
12330
  "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."
12331
+ "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
12332
  },
11260
12333
  {
11261
12334
  "slot": "response",
@@ -11267,7 +12340,7 @@
11267
12340
  "slot": "transition",
11268
12341
  "recipe": "custom",
11269
12342
  "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."
12343
+ "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
12344
  },
11272
12345
  {
11273
12346
  "slot": "activity",
@@ -13009,7 +14082,7 @@
13009
14082
  },
13010
14083
  {
13011
14084
  "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>",
14085
+ "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
14086
  "imports": "import { Button, Icon, MiddleTruncate, Row, useCopy } from \"@sableui/react\""
13014
14087
  }
13015
14088
  ]
@@ -13305,6 +14378,38 @@
13305
14378
  "success",
13306
14379
  "destructive"
13307
14380
  ],
14381
+ "motion": [
14382
+ {
14383
+ "slot": "arrival",
14384
+ "recipe": "none",
14385
+ "part": "Root",
14386
+ "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."
14387
+ },
14388
+ {
14389
+ "slot": "response",
14390
+ "recipe": "motion-interactive",
14391
+ "part": "Action",
14392
+ "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."
14393
+ },
14394
+ {
14395
+ "slot": "response",
14396
+ "recipe": "motion-press",
14397
+ "part": "Action",
14398
+ "why": "A note's action usually navigates or opens something; the small settle on press is the only confirmation before the next surface paints."
14399
+ },
14400
+ {
14401
+ "slot": "transition",
14402
+ "recipe": "none",
14403
+ "part": "Root",
14404
+ "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."
14405
+ },
14406
+ {
14407
+ "slot": "activity",
14408
+ "recipe": "none",
14409
+ "part": "Root",
14410
+ "why": "Static guidance. Work in progress belongs in a Progress or a Spinner, not in a callout."
14411
+ }
14412
+ ],
13308
14413
  "a11y": [
13309
14414
  "Rendered with role=\"note\" so assistive tech can announce it as an aside.",
13310
14415
  "Color is never the sole signal — the leading icon and optional title reinforce severity."
@@ -13338,7 +14443,7 @@
13338
14443
  "group": "Badges & pills",
13339
14444
  "register": "either",
13340
14445
  "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.",
14446
+ "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
14447
  "keywords": [
13343
14448
  "notification",
13344
14449
  "badge",
@@ -13373,7 +14478,8 @@
13373
14478
  "related": {
13374
14479
  "components": [
13375
14480
  "Badge",
13376
- "StatusDot"
14481
+ "StatusDot",
14482
+ "NumberTicker"
13377
14483
  ]
13378
14484
  },
13379
14485
  "props": [
@@ -13411,6 +14517,12 @@
13411
14517
  "type": "boolean",
13412
14518
  "default": "false",
13413
14519
  "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."
14520
+ },
14521
+ {
14522
+ "name": "motion",
14523
+ "type": "\"full\" | \"subtle\" | \"off\"",
14524
+ "default": "\"full\"",
14525
+ "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
14526
  }
13415
14527
  ],
13416
14528
  "variants": {
@@ -13437,6 +14549,32 @@
13437
14549
  "secondary",
13438
14550
  "secondary-foreground"
13439
14551
  ],
14552
+ "motion": [
14553
+ {
14554
+ "slot": "arrival",
14555
+ "recipe": "motion-appear",
14556
+ "part": "root",
14557
+ "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."
14558
+ },
14559
+ {
14560
+ "slot": "response",
14561
+ "recipe": "none",
14562
+ "part": "root",
14563
+ "why": "It is decorative and not interactive; the host it sits on answers the pointer."
14564
+ },
14565
+ {
14566
+ "slot": "transition",
14567
+ "recipe": "custom",
14568
+ "part": "Count",
14569
+ "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."
14570
+ },
14571
+ {
14572
+ "slot": "activity",
14573
+ "recipe": "none",
14574
+ "part": "root",
14575
+ "why": "An unread count has no working state."
14576
+ }
14577
+ ],
13440
14578
  "a11y": [
13441
14579
  "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
14580
  "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."
@@ -13568,6 +14706,176 @@
13568
14706
  }
13569
14707
  ]
13570
14708
  },
14709
+ {
14710
+ "name": "NumberTicker",
14711
+ "status": "stable",
14712
+ "group": "Data display",
14713
+ "register": "either",
14714
+ "path": "components/number-ticker",
14715
+ "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.",
14716
+ "keywords": [
14717
+ "number",
14718
+ "counter",
14719
+ "odometer",
14720
+ "rolling number",
14721
+ "animated number",
14722
+ "ticker",
14723
+ "count up",
14724
+ "stat",
14725
+ "kpi",
14726
+ "metric",
14727
+ "price"
14728
+ ],
14729
+ "usage": {
14730
+ "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.",
14731
+ "bestPractices": [
14732
+ {
14733
+ "guidance": "do",
14734
+ "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."
14735
+ },
14736
+ {
14737
+ "guidance": "do",
14738
+ "description": "Format through `format` (currency, percent, compact) rather than pre-formatting a string, so separators and units stay put while only the digits roll."
14739
+ },
14740
+ {
14741
+ "guidance": "do",
14742
+ "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."
14743
+ },
14744
+ {
14745
+ "guidance": "dont",
14746
+ "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."
14747
+ },
14748
+ {
14749
+ "guidance": "dont",
14750
+ "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."
14751
+ },
14752
+ {
14753
+ "guidance": "dont",
14754
+ "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)."
14755
+ },
14756
+ {
14757
+ "guidance": "dont",
14758
+ "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.",
14759
+ "instead": [
14760
+ "Meter"
14761
+ ]
14762
+ }
14763
+ ]
14764
+ },
14765
+ "related": {
14766
+ "components": [
14767
+ "Meter",
14768
+ "NotificationBadge",
14769
+ "PieChart",
14770
+ "RadialChart",
14771
+ "Sparkline"
14772
+ ]
14773
+ },
14774
+ "props": [
14775
+ {
14776
+ "name": "value",
14777
+ "type": "number",
14778
+ "description": "The number to show. When it changes, the digits roll to it."
14779
+ },
14780
+ {
14781
+ "name": "format",
14782
+ "type": "Intl.NumberFormatOptions",
14783
+ "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)."
14784
+ },
14785
+ {
14786
+ "name": "locales",
14787
+ "type": "string | string[]",
14788
+ "default": "runtime locale",
14789
+ "description": "BCP 47 locale(s) for the formatting. Digits roll through the locale's own numbering system."
14790
+ },
14791
+ {
14792
+ "name": "prefix",
14793
+ "type": "ReactNode",
14794
+ "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."
14795
+ },
14796
+ {
14797
+ "name": "suffix",
14798
+ "type": "ReactNode",
14799
+ "description": "After the number (\"+\", \" pts\"). A string becomes part of the number's text; an element is rendered as given."
14800
+ },
14801
+ {
14802
+ "name": "trend",
14803
+ "type": "\"auto\" | \"up\" | \"down\"",
14804
+ "default": "\"auto\"",
14805
+ "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)."
14806
+ },
14807
+ {
14808
+ "name": "motion",
14809
+ "type": "\"full\" | \"subtle\" | \"off\"",
14810
+ "default": "\"full\"",
14811
+ "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."
14812
+ },
14813
+ {
14814
+ "name": "className",
14815
+ "type": "string",
14816
+ "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."
14817
+ },
14818
+ {
14819
+ "name": "...span props",
14820
+ "type": "Omit<ComponentPropsWithRef<\"span\">, \"children\" | \"prefix\">",
14821
+ "description": "Remaining span attributes pass through to the root — `aria-live` to announce changes, `id`, data attributes."
14822
+ }
14823
+ ],
14824
+ "tokensUsed": [],
14825
+ "motion": [
14826
+ {
14827
+ "slot": "arrival",
14828
+ "recipe": "none",
14829
+ "part": "Root",
14830
+ "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."
14831
+ },
14832
+ {
14833
+ "slot": "response",
14834
+ "recipe": "none",
14835
+ "part": "Root",
14836
+ "why": "It is a reading, not a control; the control that changes it gives the feedback."
14837
+ },
14838
+ {
14839
+ "slot": "transition",
14840
+ "recipe": "custom",
14841
+ "part": "Digit",
14842
+ "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."
14843
+ },
14844
+ {
14845
+ "slot": "activity",
14846
+ "recipe": "none",
14847
+ "part": "Root",
14848
+ "why": "A number has no working state; show a Skeleton while it loads."
14849
+ }
14850
+ ],
14851
+ "a11y": [
14852
+ "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.",
14853
+ "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.",
14854
+ "Tabular figures keep every digit the same width, so a roll never shifts the text around it."
14855
+ ],
14856
+ "examples": [
14857
+ {
14858
+ "label": "Basic",
14859
+ "code": "<NumberTicker value={1284} className=\"text-heading-2\" />",
14860
+ "imports": "import { NumberTicker } from \"@sableui/react\""
14861
+ },
14862
+ {
14863
+ "label": "Currency",
14864
+ "code": "<NumberTicker value={4280.5} format={{ style: \"currency\", currency: \"USD\" }} className=\"text-display-lg\" />",
14865
+ "imports": "import { NumberTicker } from \"@sableui/react\""
14866
+ },
14867
+ {
14868
+ "label": "Percent, announced",
14869
+ "code": "<NumberTicker value={0.428} format={{ style: \"percent\", maximumFractionDigits: 1 }} aria-live=\"polite\" />",
14870
+ "imports": "import { NumberTicker } from \"@sableui/react\""
14871
+ },
14872
+ {
14873
+ "label": "Subtle",
14874
+ "code": "<NumberTicker value={92} suffix=\" pts\" motion=\"subtle\" />",
14875
+ "imports": "import { NumberTicker } from \"@sableui/react\""
14876
+ }
14877
+ ]
14878
+ },
13571
14879
  {
13572
14880
  "name": "OtpField",
13573
14881
  "status": "stable",
@@ -13606,6 +14914,10 @@
13606
14914
  "guidance": "dont",
13607
14915
  "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."
13608
14916
  },
14917
+ {
14918
+ "guidance": "do",
14919
+ "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."
14920
+ },
13609
14921
  {
13610
14922
  "guidance": "dont",
13611
14923
  "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."
@@ -13699,7 +15011,13 @@
13699
15011
  {
13700
15012
  "name": "aria-invalid",
13701
15013
  "type": "boolean",
13702
- "description": "Marks every slot invalid (destructive border). `<Field error>` sets it for you."
15014
+ "description": "Marks every slot invalid (destructive border and digits). Turning it on after mount shakes the field once. `<Field error>` sets it for you."
15015
+ },
15016
+ {
15017
+ "name": "motion",
15018
+ "type": "\"full\" | \"subtle\" | \"off\"",
15019
+ "default": "\"full\"",
15020
+ "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
15021
  },
13704
15022
  {
13705
15023
  "name": "disabled / readOnly / required / name / id / form",
@@ -13736,6 +15054,12 @@
13736
15054
  "part": "Slot",
13737
15055
  "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
15056
  },
15057
+ {
15058
+ "slot": "transition",
15059
+ "recipe": "motion-shake",
15060
+ "part": "Root",
15061
+ "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."
15062
+ },
13739
15063
  {
13740
15064
  "slot": "activity",
13741
15065
  "recipe": "none",
@@ -13747,7 +15071,8 @@
13747
15071
  "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
15072
  "Only one slot is in the tab order. Arrow keys, Home/End and Backspace move between slots; Tab leaves the whole field.",
13749
15073
  "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."
15074
+ "Slots render at 20px (`text-mono-lg`), above the 16px floor that stops iOS Safari zooming on focus.",
15075
+ "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
15076
  ],
13752
15077
  "examples": [
13753
15078
  {
@@ -13765,6 +15090,11 @@
13765
15090
  "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
15091
  "imports": "import { Field, OtpField } from \"@sableui/react\""
13767
15092
  },
15093
+ {
15094
+ "label": "Verify on completion, shake on a wrong code",
15095
+ "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>",
15096
+ "imports": "import { Field, OtpField } from \"@sableui/react\""
15097
+ },
13768
15098
  {
13769
15099
  "label": "Alphanumeric",
13770
15100
  "code": "<Field label=\"Pairing code\"><OtpField length={8} groupSize={4} validationType=\"alphanumeric\" /></Field>",
@@ -14355,6 +15685,32 @@
14355
15685
  "hairline",
14356
15686
  "hairline-strong"
14357
15687
  ],
15688
+ "motion": [
15689
+ {
15690
+ "slot": "arrival",
15691
+ "recipe": "none",
15692
+ "part": "Root",
15693
+ "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."
15694
+ },
15695
+ {
15696
+ "slot": "response",
15697
+ "recipe": "motion-interactive",
15698
+ "part": "PanelResizer",
15699
+ "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."
15700
+ },
15701
+ {
15702
+ "slot": "transition",
15703
+ "recipe": "none",
15704
+ "part": "Panel",
15705
+ "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."
15706
+ },
15707
+ {
15708
+ "slot": "activity",
15709
+ "recipe": "none",
15710
+ "part": "Root",
15711
+ "why": "Layout chrome has no working state."
15712
+ }
15713
+ ],
14358
15714
  "a11y": [
14359
15715
  "PanelResizer is a role=separator with aria-orientation, aria-valuenow/min/max, and is focusable — a pointer-only resizer is a keyboard trap.",
14360
15716
  "Arrow keys resize by `step`; Home and End jump to the minimum and maximum.",
@@ -14495,6 +15851,38 @@
14495
15851
  "muted-foreground",
14496
15852
  "hairline"
14497
15853
  ],
15854
+ "motion": [
15855
+ {
15856
+ "slot": "arrival",
15857
+ "recipe": "none",
15858
+ "part": "Root",
15859
+ "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."
15860
+ },
15861
+ {
15862
+ "slot": "response",
15863
+ "recipe": "motion-interactive",
15864
+ "part": "PropertyGroup trigger",
15865
+ "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."
15866
+ },
15867
+ {
15868
+ "slot": "transition",
15869
+ "recipe": "motion-disclosure",
15870
+ "part": "PropertyGroup panel",
15871
+ "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."
15872
+ },
15873
+ {
15874
+ "slot": "transition",
15875
+ "recipe": "motion-icon-swap",
15876
+ "part": "PropertyGroup indicator",
15877
+ "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."
15878
+ },
15879
+ {
15880
+ "slot": "activity",
15881
+ "recipe": "none",
15882
+ "part": "Root",
15883
+ "why": "The inspector edits values in place; nothing in it loads."
15884
+ }
15885
+ ],
14498
15886
  "a11y": [
14499
15887
  "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
15888
  "PropertyRow renders a real <label>; pass htmlFor with the control's id so clicking the label focuses it.",
@@ -14740,11 +16128,18 @@
14740
16128
  "default": "runtime locale",
14741
16129
  "description": "BCP 47 locale for default number and date formatting."
14742
16130
  },
16131
+ {
16132
+ "name": "motion",
16133
+ "type": "\"full\" | \"subtle\" | \"off\"",
16134
+ "default": "\"full\"",
16135
+ "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."
16136
+ },
14743
16137
  {
14744
16138
  "name": "animate",
14745
16139
  "type": "boolean",
14746
16140
  "default": "true",
14747
- "description": "Sweep the slices in clockwise when the chart scrolls into view."
16141
+ "description": "Sweep the slices in clockwise when the chart scrolls into view. `animate={false}` is `motion=\"subtle\"`.",
16142
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to: no arrival, hover kept) or motion=\"off\""
14748
16143
  },
14749
16144
  {
14750
16145
  "name": "activeIndex",
@@ -14781,19 +16176,25 @@
14781
16176
  "slot": "arrival",
14782
16177
  "recipe": "custom",
14783
16178
  "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."
16179
+ "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
16180
  },
14786
16181
  {
14787
16182
  "slot": "response",
14788
16183
  "recipe": "none",
14789
16184
  "part": "Active slice",
14790
- "why": "The hovered slice pulls outward and the rest dim at once; the geometry change itself eases (see transition)."
16185
+ "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."
16186
+ },
16187
+ {
16188
+ "slot": "transition",
16189
+ "recipe": "custom",
16190
+ "part": "Centre value",
16191
+ "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
16192
  },
14792
16193
  {
14793
16194
  "slot": "transition",
14794
16195
  "recipe": "custom",
14795
16196
  "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."
16197
+ "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
16198
  },
14798
16199
  {
14799
16200
  "slot": "activity",
@@ -14943,6 +16344,38 @@
14943
16344
  "muted-foreground",
14944
16345
  "foreground"
14945
16346
  ],
16347
+ "motion": [
16348
+ {
16349
+ "slot": "arrival",
16350
+ "recipe": "none",
16351
+ "part": "Root",
16352
+ "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."
16353
+ },
16354
+ {
16355
+ "slot": "response",
16356
+ "recipe": "motion-interactive",
16357
+ "part": "Root",
16358
+ "why": "The hover wash eases, so sweeping the pointer along a filter row reads as one cursor moving rather than chips flashing in turn."
16359
+ },
16360
+ {
16361
+ "slot": "response",
16362
+ "recipe": "motion-press",
16363
+ "part": "Root",
16364
+ "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."
16365
+ },
16366
+ {
16367
+ "slot": "transition",
16368
+ "recipe": "motion-interactive",
16369
+ "part": "Root",
16370
+ "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."
16371
+ },
16372
+ {
16373
+ "slot": "activity",
16374
+ "recipe": "none",
16375
+ "part": "Root",
16376
+ "why": "A chip is a choice, not a process. If applying it is slow, the results show it."
16377
+ }
16378
+ ],
14946
16379
  "a11y": [
14947
16380
  "Renders a native <button type=\"button\"> for focus and keyboard activation.",
14948
16381
  "Selected state is exposed via aria-pressed."
@@ -15423,11 +16856,18 @@
15423
16856
  "default": "runtime locale",
15424
16857
  "description": "BCP 47 locale for default number and date formatting."
15425
16858
  },
16859
+ {
16860
+ "name": "motion",
16861
+ "type": "\"full\" | \"subtle\" | \"off\"",
16862
+ "default": "\"full\"",
16863
+ "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."
16864
+ },
15426
16865
  {
15427
16866
  "name": "animate",
15428
16867
  "type": "boolean",
15429
16868
  "default": "true",
15430
- "description": "Grow the polygons from the centre when the chart scrolls into view."
16869
+ "description": "Grow the polygons from the centre when the chart scrolls into view. `animate={false}` is `motion=\"subtle\"`.",
16870
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to: no arrival, hover kept) or motion=\"off\""
15431
16871
  },
15432
16872
  {
15433
16873
  "name": "activeIndex",
@@ -15464,7 +16904,7 @@
15464
16904
  "slot": "arrival",
15465
16905
  "recipe": "custom",
15466
16906
  "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."
16907
+ "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
16908
  },
15469
16909
  {
15470
16910
  "slot": "response",
@@ -15476,7 +16916,7 @@
15476
16916
  "slot": "transition",
15477
16917
  "recipe": "custom",
15478
16918
  "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."
16919
+ "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
16920
  },
15481
16921
  {
15482
16922
  "slot": "activity",
@@ -15640,11 +17080,18 @@
15640
17080
  "default": "runtime locale",
15641
17081
  "description": "BCP 47 locale for default number and date formatting."
15642
17082
  },
17083
+ {
17084
+ "name": "motion",
17085
+ "type": "\"full\" | \"subtle\" | \"off\"",
17086
+ "default": "\"full\"",
17087
+ "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."
17088
+ },
15643
17089
  {
15644
17090
  "name": "animate",
15645
17091
  "type": "boolean",
15646
17092
  "default": "true",
15647
- "description": "Draw the rings round from their start when the chart scrolls into view."
17093
+ "description": "Draw the rings round from their start when the chart scrolls into view. `animate={false}` is `motion=\"subtle\"`.",
17094
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to: no arrival, hover kept) or motion=\"off\""
15648
17095
  },
15649
17096
  {
15650
17097
  "name": "activeIndex",
@@ -15681,19 +17128,25 @@
15681
17128
  "slot": "arrival",
15682
17129
  "recipe": "custom",
15683
17130
  "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."
17131
+ "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
17132
  },
15686
17133
  {
15687
17134
  "slot": "response",
15688
17135
  "recipe": "none",
15689
17136
  "part": "Active ring",
15690
- "why": "The hovered ring stays full strength and the others dim at once."
17137
+ "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."
17138
+ },
17139
+ {
17140
+ "slot": "transition",
17141
+ "recipe": "custom",
17142
+ "part": "Centre value",
17143
+ "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
17144
  },
15692
17145
  {
15693
17146
  "slot": "transition",
15694
17147
  "recipe": "custom",
15695
17148
  "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."
17149
+ "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
17150
  },
15698
17151
  {
15699
17152
  "slot": "activity",
@@ -15799,6 +17252,32 @@
15799
17252
  "primary",
15800
17253
  "ring"
15801
17254
  ],
17255
+ "motion": [
17256
+ {
17257
+ "slot": "arrival",
17258
+ "recipe": "none",
17259
+ "part": "Root",
17260
+ "why": "Radios arrive with the form they belong to. Animating each one in turns a question into a title sequence."
17261
+ },
17262
+ {
17263
+ "slot": "response",
17264
+ "recipe": "motion-interactive",
17265
+ "part": "Root",
17266
+ "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."
17267
+ },
17268
+ {
17269
+ "slot": "transition",
17270
+ "recipe": "motion-interactive",
17271
+ "part": "Indicator",
17272
+ "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."
17273
+ },
17274
+ {
17275
+ "slot": "activity",
17276
+ "recipe": "none",
17277
+ "part": "Root",
17278
+ "why": "A radio commits instantly. A slow write belongs to the surrounding form's pending state, not the control."
17279
+ }
17280
+ ],
15802
17281
  "a11y": [
15803
17282
  "Base UI provides the radiogroup/radio roles, aria-checked, hidden native inputs, and roving tabindex.",
15804
17283
  "Arrow keys move and select between items; Space selects the focused item; Tab enters/leaves the group.",
@@ -15951,6 +17430,44 @@
15951
17430
  "tone-info",
15952
17431
  "tone-warning"
15953
17432
  ],
17433
+ "motion": [
17434
+ {
17435
+ "slot": "arrival",
17436
+ "recipe": "none",
17437
+ "part": "Root",
17438
+ "why": "Cards arrive with the form. A plan picker that animated each card in would delay the choice it exists to make."
17439
+ },
17440
+ {
17441
+ "slot": "response",
17442
+ "recipe": "motion-interactive",
17443
+ "part": "Root",
17444
+ "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."
17445
+ },
17446
+ {
17447
+ "slot": "response",
17448
+ "recipe": "motion-press",
17449
+ "part": "Root",
17450
+ "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."
17451
+ },
17452
+ {
17453
+ "slot": "transition",
17454
+ "recipe": "motion-interactive",
17455
+ "part": "Root",
17456
+ "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."
17457
+ },
17458
+ {
17459
+ "slot": "transition",
17460
+ "recipe": "motion-interactive",
17461
+ "part": "Indicator",
17462
+ "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."
17463
+ },
17464
+ {
17465
+ "slot": "activity",
17466
+ "recipe": "none",
17467
+ "part": "Root",
17468
+ "why": "A choice commits instantly. A slow write belongs to the surrounding form's pending state."
17469
+ }
17470
+ ],
15954
17471
  "a11y": [
15955
17472
  "Base UI provides the radiogroup/radio roles, aria-checked, hidden native inputs, and roving tabindex.",
15956
17473
  "Arrow keys move and select between cards; Space selects the focused card; Tab enters/leaves the group.",
@@ -15976,6 +17493,178 @@
15976
17493
  }
15977
17494
  ]
15978
17495
  },
17496
+ {
17497
+ "name": "ReactionPicker",
17498
+ "status": "stable",
17499
+ "group": "Actions",
17500
+ "register": "either",
17501
+ "path": "components/reaction-picker",
17502
+ "summary": "The quick row of emoji you react to a message with: a toolbar of large emoji toggles that bloom in one after another as it opens and magnify under the pointer, with MenuReactions as the same row inside a right-click menu.",
17503
+ "keywords": [
17504
+ "reaction",
17505
+ "reactions",
17506
+ "emoji",
17507
+ "emoji picker",
17508
+ "react",
17509
+ "tapback",
17510
+ "quick reactions",
17511
+ "message actions",
17512
+ "chat"
17513
+ ],
17514
+ "usage": {
17515
+ "description": "Use ReactionPicker wherever a reader chooses a reaction for something: in a Popover from an add-reaction control (BubbleReactionAdd is that, ready-made), or as a hover bar over a message. Use MenuReactions for the same row as the first group of the menu a message opens on right-click or long-press, above Reply and Copy. Both report which emoji the reader has already used, so choosing one again removes it.",
17516
+ "bestPractices": [
17517
+ {
17518
+ "guidance": "do",
17519
+ "description": "Keep the quick set short — six to eight. It is the reactions people actually use; a full emoji catalogue belongs behind a trailing \"more\" control passed as children."
17520
+ },
17521
+ {
17522
+ "guidance": "do",
17523
+ "description": "Give every reaction a `label`. It is the accessible name and the menu's typeahead, and an emoji's own spoken name differs between platforms."
17524
+ },
17525
+ {
17526
+ "guidance": "do",
17527
+ "description": "Pass `selected` so the reader's own reactions show pressed, and toggle in `onSelect`: choosing a reaction you already gave takes it back."
17528
+ },
17529
+ {
17530
+ "guidance": "do",
17531
+ "description": "Offer the right-click menu and a visible add-reaction control together. A context menu is invisible until you know it is there; the pill is how a first-time reader finds reactions."
17532
+ },
17533
+ {
17534
+ "guidance": "dont",
17535
+ "description": "Do not put a ReactionPicker inside a Menu or ContextMenu. A toolbar is not a menu item, so the menu's arrow keys would skip it; use MenuReactions there.",
17536
+ "instead": [
17537
+ "MenuReactions"
17538
+ ]
17539
+ },
17540
+ {
17541
+ "guidance": "dont",
17542
+ "description": "Do not use it to rate an answer or collect a verdict. Reactions are social and many-valued; a helpful / not-helpful question is Feedback.",
17543
+ "instead": [
17544
+ "Feedback"
17545
+ ]
17546
+ }
17547
+ ],
17548
+ "anatomy": [
17549
+ {
17550
+ "name": "ReactionPicker",
17551
+ "required": true,
17552
+ "description": "The toolbar: one tab stop, arrow keys move between emoji, each a toggle with `aria-pressed`."
17553
+ },
17554
+ {
17555
+ "name": "MenuReactions",
17556
+ "required": false,
17557
+ "description": "The same row as a group of checkbox menu items, for MenuContent and ContextMenuContent. Choosing one closes the menu."
17558
+ }
17559
+ ]
17560
+ },
17561
+ "related": {
17562
+ "components": [
17563
+ "Bubble",
17564
+ "ContextMenu",
17565
+ "Popover",
17566
+ "Toolbar",
17567
+ "Feedback"
17568
+ ]
17569
+ },
17570
+ "props": [
17571
+ {
17572
+ "name": "reactions",
17573
+ "type": "readonly { emoji: string; label: string }[]",
17574
+ "default": "DEFAULT_REACTIONS",
17575
+ "description": "The choices, in order. The default is 👍 ❤️ 😂 🎉 😮 😢 🙏."
17576
+ },
17577
+ {
17578
+ "name": "selected",
17579
+ "type": "readonly string[]",
17580
+ "default": "[]",
17581
+ "description": "The emoji the reader has already reacted with: shown pressed, and choosing one again removes it."
17582
+ },
17583
+ {
17584
+ "name": "onSelect",
17585
+ "type": "(emoji: string) => void",
17586
+ "description": "Called with the emoji the reader chose. Toggle it in or out of `selected`."
17587
+ },
17588
+ {
17589
+ "name": "children",
17590
+ "type": "ReactNode",
17591
+ "description": "ReactionPicker only: trailing controls after the emoji, such as a button that opens a full emoji picker."
17592
+ },
17593
+ {
17594
+ "name": "aria-label",
17595
+ "type": "string",
17596
+ "default": "\"Reactions\"",
17597
+ "description": "The name of the toolbar or menu group."
17598
+ },
17599
+ {
17600
+ "name": "motion",
17601
+ "type": "\"full\" | \"subtle\" | \"off\"",
17602
+ "default": "\"full\"",
17603
+ "description": "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps the press and drops the wave of emoji blooming in and the magnify, `off` stills it. It can only turn motion down."
17604
+ }
17605
+ ],
17606
+ "subcomponents": [
17607
+ {
17608
+ "name": "MenuReactions",
17609
+ "type": "{ reactions?; selected?; onSelect?; motion? } & ComponentProps<typeof Menu.Group>",
17610
+ "description": "The quick row as checkbox menu items, for the first group of a MenuContent or ContextMenuContent. Same props as ReactionPicker, without children."
17611
+ }
17612
+ ],
17613
+ "tokensUsed": [
17614
+ "accent",
17615
+ "primary",
17616
+ "ring"
17617
+ ],
17618
+ "motion": [
17619
+ {
17620
+ "slot": "arrival",
17621
+ "recipe": "motion-bloom",
17622
+ "part": "Glyph",
17623
+ "why": "Opening the picker is the reader's own action, so the emoji answer it: each blooms in 24ms after the one before, a short wave from first to last. The surface it sits in (a Popover or a menu) arrives with its own motion-pop. Expressive only — under `subtle` the emoji are simply there."
17624
+ },
17625
+ {
17626
+ "slot": "response",
17627
+ "recipe": "custom",
17628
+ "part": "Glyph",
17629
+ "why": "The emoji under the pointer or the keyboard highlight magnifies to 1.25 on a fast standard ease, so you can see which one a release will choose; each cell also carries motion-interactive and motion-press. The magnify rides an inner layer so it never fights the bloom over `scale`."
17630
+ },
17631
+ {
17632
+ "slot": "transition",
17633
+ "recipe": "motion-interactive",
17634
+ "part": "Cell",
17635
+ "why": "Pressed and not pressed differ by a tint, eased by motion-interactive. The celebration belongs to the reaction that lands under the message (BubbleReaction), not to the picker that chose it."
17636
+ },
17637
+ {
17638
+ "slot": "activity",
17639
+ "recipe": "none",
17640
+ "part": "Root",
17641
+ "why": "Choosing is instant; there is no in-between state to show."
17642
+ }
17643
+ ],
17644
+ "a11y": [
17645
+ "ReactionPicker is a Base UI toolbar: one tab stop, Left/Right move between emoji, Home/End jump to the ends.",
17646
+ "Each emoji is a button named by its `label` and reporting `aria-pressed`, so a screen-reader user hears whether choosing it adds or removes their reaction. The glyph itself is aria-hidden.",
17647
+ "MenuReactions renders checkbox menu items (`menuitemcheckbox` with `aria-checked`), reached by the menu's arrow keys and typeahead like every other row.",
17648
+ "Under reduced motion the emoji appear at once and do not magnify; pressed state is carried by `aria-pressed` and a tint, never motion."
17649
+ ],
17650
+ "examples": [
17651
+ {
17652
+ "label": "In a popover",
17653
+ "code": "<Popover>\n\t<PopoverTrigger render={<Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Add reaction\" />}>\n\t\t<Icon name=\"emoji\" />\n\t</PopoverTrigger>\n\t<PopoverContent side=\"top\" className=\"w-auto rounded-full p-1\">\n\t\t<ReactionPicker selected={mine} onSelect={toggle} />\n\t</PopoverContent>\n</Popover>",
17654
+ "imports": "import { Button, Icon, Popover, PopoverContent, PopoverTrigger, ReactionPicker } from \"@sableui/react\""
17655
+ },
17656
+ {
17657
+ "label": "In a right-click menu",
17658
+ "code": "<ContextMenu>\n\t<ContextMenuTrigger render={<Bubble />}>See you at 6.</ContextMenuTrigger>\n\t<ContextMenuContent>\n\t\t<MenuReactions selected={mine} onSelect={toggle} />\n\t\t<ContextMenuSeparator />\n\t\t<ContextMenuItem>Reply</ContextMenuItem>\n\t\t<ContextMenuItem>Copy text</ContextMenuItem>\n\t</ContextMenuContent>\n</ContextMenu>",
17659
+ "imports": "import { Bubble, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, MenuReactions } from \"@sableui/react\""
17660
+ },
17661
+ {
17662
+ "label": "A custom set",
17663
+ "code": "<ReactionPicker\n\treactions={[\n\t\t{ emoji: \"✅\", label: \"Done\" },\n\t\t{ emoji: \"👀\", label: \"Looking\" },\n\t\t{ emoji: \"🚀\", label: \"Shipped\" },\n\t]}\n\tselected={mine}\n\tonSelect={toggle}\n/>",
17664
+ "imports": "import { ReactionPicker } from \"@sableui/react\""
17665
+ }
17666
+ ]
17667
+ },
15979
17668
  {
15980
17669
  "name": "RelativeTime",
15981
17670
  "status": "stable",
@@ -16039,6 +17728,38 @@
16039
17728
  "border",
16040
17729
  "ring"
16041
17730
  ],
17731
+ "motion": [
17732
+ {
17733
+ "slot": "arrival",
17734
+ "recipe": "none",
17735
+ "part": "Root",
17736
+ "why": "It is a word in a line of text and arrives with that line."
17737
+ },
17738
+ {
17739
+ "slot": "response",
17740
+ "recipe": "none",
17741
+ "part": "Root",
17742
+ "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."
17743
+ },
17744
+ {
17745
+ "slot": "response",
17746
+ "recipe": "motion-pop",
17747
+ "part": "Tooltip",
17748
+ "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."
17749
+ },
17750
+ {
17751
+ "slot": "transition",
17752
+ "recipe": "none",
17753
+ "part": "Root",
17754
+ "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."
17755
+ },
17756
+ {
17757
+ "slot": "activity",
17758
+ "recipe": "none",
17759
+ "part": "Root",
17760
+ "why": "A timestamp has nothing to wait for."
17761
+ }
17762
+ ],
16042
17763
  "a11y": [
16043
17764
  "Renders a semantic <time> element with a machine-readable dateTime (ISO) attribute.",
16044
17765
  "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 +17884,38 @@
16163
17884
  "tokensUsed": [
16164
17885
  "ring"
16165
17886
  ],
17887
+ "motion": [
17888
+ {
17889
+ "slot": "arrival",
17890
+ "recipe": "none",
17891
+ "part": "Root",
17892
+ "why": "It is a region, not an event; its content arrives with whatever fills it."
17893
+ },
17894
+ {
17895
+ "slot": "response",
17896
+ "recipe": "motion-interactive",
17897
+ "part": "Viewport",
17898
+ "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."
17899
+ },
17900
+ {
17901
+ "slot": "response",
17902
+ "recipe": "custom",
17903
+ "part": "Scrollbar",
17904
+ "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."
17905
+ },
17906
+ {
17907
+ "slot": "transition",
17908
+ "recipe": "none",
17909
+ "part": "Edge fade",
17910
+ "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."
17911
+ },
17912
+ {
17913
+ "slot": "activity",
17914
+ "recipe": "none",
17915
+ "part": "Root",
17916
+ "why": "A scroll region does no work of its own. Content still loading inside it brings its own Skeleton or Spinner."
17917
+ }
17918
+ ],
16166
17919
  "a11y": [
16167
17920
  "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
17921
  "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 +18083,38 @@
16330
18083
  "panel-field",
16331
18084
  "panel-field-hover"
16332
18085
  ],
18086
+ "motion": [
18087
+ {
18088
+ "slot": "arrival",
18089
+ "recipe": "none",
18090
+ "part": "Root",
18091
+ "why": "It arrives with the inspector row it sits in."
18092
+ },
18093
+ {
18094
+ "slot": "response",
18095
+ "recipe": "motion-interactive",
18096
+ "part": "Root",
18097
+ "why": "The shell eases its focus ring like every field, so tabbing through a column of numbers shows where the keyboard is without flicker."
18098
+ },
18099
+ {
18100
+ "slot": "response",
18101
+ "recipe": "motion-interactive",
18102
+ "part": "Handle",
18103
+ "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."
18104
+ },
18105
+ {
18106
+ "slot": "transition",
18107
+ "recipe": "none",
18108
+ "part": "Value",
18109
+ "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."
18110
+ },
18111
+ {
18112
+ "slot": "activity",
18113
+ "recipe": "none",
18114
+ "part": "Root",
18115
+ "why": "Scrubbing is synchronous; there is nothing to wait for."
18116
+ }
18117
+ ],
16333
18118
  "a11y": [
16334
18119
  "The input carries the accessible name via `label`; the glyph is aria-hidden because it is decorative.",
16335
18120
  "Every scrub gesture is reachable from the keyboard: ArrowUp/ArrowDown step by `step`, Shift+Arrow by ten steps.",
@@ -16460,6 +18245,32 @@
16460
18245
  "ring",
16461
18246
  "destructive"
16462
18247
  ],
18248
+ "motion": [
18249
+ {
18250
+ "slot": "arrival",
18251
+ "recipe": "motion-appear",
18252
+ "part": "Clear button",
18253
+ "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."
18254
+ },
18255
+ {
18256
+ "slot": "response",
18257
+ "recipe": "motion-interactive",
18258
+ "part": "Input",
18259
+ "why": "The focus ring and invalid border ease in, so focus reads as the field answering rather than a repaint."
18260
+ },
18261
+ {
18262
+ "slot": "transition",
18263
+ "recipe": "none",
18264
+ "part": "Root",
18265
+ "why": "The field has no states of its own to move between — the clear button arriving is its only change, and arrival covers it."
18266
+ },
18267
+ {
18268
+ "slot": "activity",
18269
+ "recipe": "none",
18270
+ "part": "Root",
18271
+ "why": "Searching is the results' job: a list skeleton or a Spinner beside them, not a field that animates while you type."
18272
+ }
18273
+ ],
16463
18274
  "a11y": [
16464
18275
  "Renders a native <input type=\"search\"> — keyboard and form semantics come for free.",
16465
18276
  "Associate a <label htmlFor> (or wrap) — the magnifier glyph is decorative, not a label.",
@@ -17619,6 +19430,44 @@
17619
19430
  "foreground",
17620
19431
  "ring"
17621
19432
  ],
19433
+ "motion": [
19434
+ {
19435
+ "slot": "arrival",
19436
+ "recipe": "none",
19437
+ "part": "Root",
19438
+ "why": "The preview arrives with the content it clips. Its fade and toggle appear only once a measurement proves the content overflows."
19439
+ },
19440
+ {
19441
+ "slot": "response",
19442
+ "recipe": "motion-interactive",
19443
+ "part": "Toggle",
19444
+ "why": "The toggle is a link Button, so its hover and focus ease on Button's recipe."
19445
+ },
19446
+ {
19447
+ "slot": "transition",
19448
+ "recipe": "custom",
19449
+ "part": "Content",
19450
+ "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."
19451
+ },
19452
+ {
19453
+ "slot": "transition",
19454
+ "recipe": "motion-interactive",
19455
+ "part": "Fade",
19456
+ "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."
19457
+ },
19458
+ {
19459
+ "slot": "transition",
19460
+ "recipe": "motion-interactive",
19461
+ "part": "Chevron",
19462
+ "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."
19463
+ },
19464
+ {
19465
+ "slot": "activity",
19466
+ "recipe": "none",
19467
+ "part": "Root",
19468
+ "why": "Expanding is local and instant; nothing loads."
19469
+ }
19470
+ ],
17622
19471
  "a11y": [
17623
19472
  "The toggle is a real <button> with aria-expanded reflecting the open state.",
17624
19473
  "The fade overlay is pointer-events-none and decorative; it never traps clicks.",
@@ -17718,6 +19567,12 @@
17718
19567
  "type": "\"rail\" | \"page\"",
17719
19568
  "default": "\"rail\"",
17720
19569
  "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."
19570
+ },
19571
+ {
19572
+ "name": "motion",
19573
+ "type": "\"full\" | \"subtle\" | \"off\"",
19574
+ "default": "\"full\"",
19575
+ "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
19576
  }
17722
19577
  ],
17723
19578
  "subcomponents": [
@@ -17751,6 +19606,38 @@
17751
19606
  "sidebar-accent-foreground",
17752
19607
  "sidebar-ring"
17753
19608
  ],
19609
+ "motion": [
19610
+ {
19611
+ "slot": "arrival",
19612
+ "recipe": "none",
19613
+ "part": "Root",
19614
+ "why": "The rail is the app's frame, present from first paint. It is not summoned, so it has no entrance."
19615
+ },
19616
+ {
19617
+ "slot": "response",
19618
+ "recipe": "motion-interactive",
19619
+ "part": "SidebarItem",
19620
+ "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."
19621
+ },
19622
+ {
19623
+ "slot": "transition",
19624
+ "recipe": "motion-glide",
19625
+ "part": "SidebarNav",
19626
+ "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."
19627
+ },
19628
+ {
19629
+ "slot": "transition",
19630
+ "recipe": "motion-size",
19631
+ "part": "Root",
19632
+ "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."
19633
+ },
19634
+ {
19635
+ "slot": "activity",
19636
+ "recipe": "none",
19637
+ "part": "Root",
19638
+ "why": "Navigation chrome has no working state; a loading route shows it in the page."
19639
+ }
19640
+ ],
17754
19641
  "a11y": [
17755
19642
  "SidebarNav renders a <nav> landmark.",
17756
19643
  "Active item carries aria-current=\"page\".",
@@ -17870,6 +19757,12 @@
17870
19757
  "type": "\"content\" | \"sidebar\"",
17871
19758
  "default": "\"content\"",
17872
19759
  "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."
19760
+ },
19761
+ {
19762
+ "name": "motion",
19763
+ "type": "\"full\" | \"subtle\" | \"off\"",
19764
+ "default": "\"full\"",
19765
+ "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
19766
  }
17874
19767
  ],
17875
19768
  "subcomponents": [
@@ -17896,6 +19789,32 @@
17896
19789
  "sidebar-accent-foreground",
17897
19790
  "sidebar-ring"
17898
19791
  ],
19792
+ "motion": [
19793
+ {
19794
+ "slot": "arrival",
19795
+ "recipe": "none",
19796
+ "part": "Root",
19797
+ "why": "In-page navigation arrives with the page it navigates."
19798
+ },
19799
+ {
19800
+ "slot": "response",
19801
+ "recipe": "motion-interactive",
19802
+ "part": "SideNavItem",
19803
+ "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."
19804
+ },
19805
+ {
19806
+ "slot": "transition",
19807
+ "recipe": "motion-glide",
19808
+ "part": "SideNav",
19809
+ "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."
19810
+ },
19811
+ {
19812
+ "slot": "activity",
19813
+ "recipe": "none",
19814
+ "part": "Root",
19815
+ "why": "Navigation chrome has no working state; a loading section shows it in the content."
19816
+ }
19817
+ ],
17899
19818
  "a11y": [
17900
19819
  "SideNav renders a <nav> landmark; pair multiple navs with aria-label to distinguish them.",
17901
19820
  "The active item carries aria-current=\"page\".",
@@ -18208,6 +20127,32 @@
18208
20127
  "background",
18209
20128
  "ring"
18210
20129
  ],
20130
+ "motion": [
20131
+ {
20132
+ "slot": "arrival",
20133
+ "recipe": "none",
20134
+ "part": "Root",
20135
+ "why": "It arrives with the form or panel it sits in."
20136
+ },
20137
+ {
20138
+ "slot": "response",
20139
+ "recipe": "motion-interactive",
20140
+ "part": "Thumb",
20141
+ "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."
20142
+ },
20143
+ {
20144
+ "slot": "transition",
20145
+ "recipe": "none",
20146
+ "part": "Thumb",
20147
+ "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."
20148
+ },
20149
+ {
20150
+ "slot": "activity",
20151
+ "recipe": "none",
20152
+ "part": "Root",
20153
+ "why": "A slider commits as it moves; there is nothing to wait for."
20154
+ }
20155
+ ],
18211
20156
  "a11y": [
18212
20157
  "Base UI provides role=slider, aria-valuemin/max/now, and arrow-key / Page Up-Down / Home-End control.",
18213
20158
  "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 +20269,18 @@
18324
20269
  "default": "\"h-8 w-24\"",
18325
20270
  "description": "Sizes it; the drawing stretches to fit."
18326
20271
  },
20272
+ {
20273
+ "name": "motion",
20274
+ "type": "\"full\" | \"subtle\" | \"off\"",
20275
+ "default": "\"full\"",
20276
+ "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."
20277
+ },
18327
20278
  {
18328
20279
  "name": "animate",
18329
20280
  "type": "boolean",
18330
20281
  "default": "true",
18331
- "description": "Draw on (or rise) when it scrolls into view."
20282
+ "description": "Draw on (or rise) when it scrolls into view. `animate={false}` is `motion=\"subtle\"`.",
20283
+ "deprecated": "motion=\"subtle\" (what animate={false} now maps to) or motion=\"off\""
18332
20284
  },
18333
20285
  {
18334
20286
  "name": "locale",
@@ -18360,7 +20312,7 @@
18360
20312
  "slot": "arrival",
18361
20313
  "recipe": "custom",
18362
20314
  "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."
20315
+ "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
20316
  },
18365
20317
  {
18366
20318
  "slot": "response",
@@ -19425,6 +21377,32 @@
19425
21377
  "muted",
19426
21378
  "muted-foreground"
19427
21379
  ],
21380
+ "motion": [
21381
+ {
21382
+ "slot": "arrival",
21383
+ "recipe": "none",
21384
+ "part": "Root",
21385
+ "why": "A table renders with the page it sits in. Staggering rows in delays reading data someone opened to scan."
21386
+ },
21387
+ {
21388
+ "slot": "response",
21389
+ "recipe": "motion-interactive",
21390
+ "part": "TableRow",
21391
+ "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."
21392
+ },
21393
+ {
21394
+ "slot": "transition",
21395
+ "recipe": "motion-interactive",
21396
+ "part": "TableCell",
21397
+ "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."
21398
+ },
21399
+ {
21400
+ "slot": "activity",
21401
+ "recipe": "none",
21402
+ "part": "Root",
21403
+ "why": "Table is markup. Loading rows are Skeletons, which carry the shimmer."
21404
+ }
21405
+ ],
19428
21406
  "a11y": [
19429
21407
  "Renders semantic native table elements (thead/tbody/tfoot/th/td/caption) preserving built-in table semantics.",
19430
21408
  "Use TableHead (th) for column headers and TableCaption for an accessible description.",
@@ -19459,7 +21437,7 @@
19459
21437
  "group": "Navigation",
19460
21438
  "register": "either",
19461
21439
  "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.",
21440
+ "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
21441
  "keywords": [
19464
21442
  "tabs",
19465
21443
  "tablist",
@@ -19533,13 +21511,19 @@
19533
21511
  "type": "Base UI Tabs.Root props (value, defaultValue, onValueChange, orientation)",
19534
21512
  "default": "defaultValue: 0",
19535
21513
  "description": "Controls the active tab. Uncontrolled via defaultValue; controlled via value + onValueChange."
21514
+ },
21515
+ {
21516
+ "name": "motion",
21517
+ "type": "\"full\" | \"subtle\" | \"off\"",
21518
+ "default": "\"full\"",
21519
+ "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
21520
  }
19537
21521
  ],
19538
21522
  "subcomponents": [
19539
21523
  {
19540
21524
  "name": "TabsList",
19541
21525
  "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."
21526
+ "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
21527
  },
19544
21528
  {
19545
21529
  "name": "TabsTrigger",
@@ -19564,11 +21548,44 @@
19564
21548
  "foreground",
19565
21549
  "ring"
19566
21550
  ],
21551
+ "motion": [
21552
+ {
21553
+ "slot": "arrival",
21554
+ "recipe": "none",
21555
+ "part": "Root",
21556
+ "why": "Tabs arrive with the section they organise. An entrance of their own would announce navigation chrome louder than the content it navigates."
21557
+ },
21558
+ {
21559
+ "slot": "response",
21560
+ "recipe": "motion-interactive",
21561
+ "part": "TabsTrigger",
21562
+ "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."
21563
+ },
21564
+ {
21565
+ "slot": "transition",
21566
+ "recipe": "motion-glide",
21567
+ "part": "Indicator",
21568
+ "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."
21569
+ },
21570
+ {
21571
+ "slot": "transition",
21572
+ "recipe": "animate-fade-in",
21573
+ "part": "TabsPanel",
21574
+ "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."
21575
+ },
21576
+ {
21577
+ "slot": "activity",
21578
+ "recipe": "none",
21579
+ "part": "Root",
21580
+ "why": "Switching tabs is synchronous. A panel that loads data shows its own Skeleton or Spinner inside the panel, where the wait is."
21581
+ }
21582
+ ],
19567
21583
  "a11y": [
19568
21584
  "Base UI provides the tablist/tab/tabpanel roles, aria-selected, and roving tabindex.",
19569
21585
  "Arrow keys move between tabs; Home/End jump to first/last; panels are linked via aria-controls.",
19570
21586
  "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."
21587
+ "Keyboard focus shows a ring on the focused trigger in both variants; the panel remains focusable.",
21588
+ "The gliding indicator is role=\"presentation\" decoration; aria-selected on the tab carries the state."
19572
21589
  ],
19573
21590
  "examples": [
19574
21591
  {
@@ -19585,6 +21602,11 @@
19585
21602
  "label": "With a disabled tab",
19586
21603
  "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
21604
  "imports": "import { Tabs, TabsList, TabsPanel, TabsTrigger } from \"@sableui/react\""
21605
+ },
21606
+ {
21607
+ "label": "Subtle motion",
21608
+ "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>",
21609
+ "imports": "import { Tabs, TabsList, TabsPanel, TabsTrigger } from \"@sableui/react\""
19588
21610
  }
19589
21611
  ]
19590
21612
  },
@@ -19835,6 +21857,38 @@
19835
21857
  "muted-foreground",
19836
21858
  "ring"
19837
21859
  ],
21860
+ "motion": [
21861
+ {
21862
+ "slot": "arrival",
21863
+ "recipe": "none",
21864
+ "part": "Root",
21865
+ "why": "It sits in a settings panel or a popover, which owns the entrance."
21866
+ },
21867
+ {
21868
+ "slot": "response",
21869
+ "recipe": "motion-interactive",
21870
+ "part": "Option (list)",
21871
+ "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."
21872
+ },
21873
+ {
21874
+ "slot": "response",
21875
+ "recipe": "motion-interactive",
21876
+ "part": "Name (grid)",
21877
+ "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."
21878
+ },
21879
+ {
21880
+ "slot": "transition",
21881
+ "recipe": "motion-interactive",
21882
+ "part": "Check (list)",
21883
+ "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."
21884
+ },
21885
+ {
21886
+ "slot": "activity",
21887
+ "recipe": "none",
21888
+ "part": "Root",
21889
+ "why": "Applying a theme is synchronous: the whole interface repaints at once, and that repaint is the feedback."
21890
+ }
21891
+ ],
19838
21892
  "a11y": [
19839
21893
  "The container is role=\"radiogroup\" with an aria-label; each card is a role=\"radio\" button with aria-checked reflecting the selected theme.",
19840
21894
  "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 +21986,12 @@
19932
21986
  "default": "\"Theme\"",
19933
21987
  "description": "Accessible label for the radiogroup."
19934
21988
  },
21989
+ {
21990
+ "name": "motion",
21991
+ "type": "\"full\" | \"subtle\" | \"off\"",
21992
+ "default": "\"full\"",
21993
+ "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."
21994
+ },
19935
21995
  {
19936
21996
  "name": "className",
19937
21997
  "type": "string",
@@ -19947,6 +22007,38 @@
19947
22007
  "accent-foreground",
19948
22008
  "ring"
19949
22009
  ],
22010
+ "motion": [
22011
+ {
22012
+ "slot": "arrival",
22013
+ "recipe": "none",
22014
+ "part": "Root",
22015
+ "why": "It arrives with the header or settings row it lives in; a theme control that announces itself competes with the page it themes."
22016
+ },
22017
+ {
22018
+ "slot": "response",
22019
+ "recipe": "motion-interactive",
22020
+ "part": "Option",
22021
+ "why": "The icon steps from muted to foreground under the pointer, which is what tells an icon-only segment it is a control."
22022
+ },
22023
+ {
22024
+ "slot": "response",
22025
+ "recipe": "motion-press",
22026
+ "part": "Option",
22027
+ "why": "A settle under the press confirms the tap on a 28px target before the theme change repaints the page around it."
22028
+ },
22029
+ {
22030
+ "slot": "transition",
22031
+ "recipe": "motion-glide",
22032
+ "part": "Active pill",
22033
+ "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."
22034
+ },
22035
+ {
22036
+ "slot": "activity",
22037
+ "recipe": "none",
22038
+ "part": "Root",
22039
+ "why": "Changing the theme is synchronous; the repaint is the feedback."
22040
+ }
22041
+ ],
19950
22042
  "a11y": [
19951
22043
  "The container is role=\"radiogroup\" with an aria-label; each option is role=\"radio\" with aria-checked reflecting the selected value.",
19952
22044
  "Each icon button carries an aria-label (Light / Dark / System) plus a title tooltip, since the visual is icon-only.",
@@ -19968,6 +22060,11 @@
19968
22060
  "label": "Light selected",
19969
22061
  "code": "<ThemeSwitcher value=\"light\" onValueChange={setTheme} />",
19970
22062
  "imports": "import { ThemeSwitcher } from \"@sableui/react\""
22063
+ },
22064
+ {
22065
+ "label": "Subtle motion",
22066
+ "code": "<ThemeSwitcher value={theme} onValueChange={setTheme} motion=\"subtle\" />",
22067
+ "imports": "import { ThemeSwitcher } from \"@sableui/react\""
19971
22068
  }
19972
22069
  ]
19973
22070
  },
@@ -20286,7 +22383,7 @@
20286
22383
  "name": "type (toast option)",
20287
22384
  "type": "\"success\" | \"error\" | \"warning\" | \"info\" | \"loading\"",
20288
22385
  "default": "—",
20289
- "description": "Set by the toast.* helpers. Drives a semantic left accent (success→success, error→destructive, warning→tone-warning, info→tone-info); loading shows a spinner. Both loading and error persist until updated or dismissed unless the call supplies a timeout."
22386
+ "description": "Set by the toast.* helpers. Drives a leading status glyph from the shared tone list, the same one Note and InlineStatus use (success→success, error→destructive, warning→warning, info→info), which blooms in as the toast lands; loading shows a spinner, and the glyph blooms in its place when the toast settles. Both loading and error persist until updated or dismissed unless the call supplies a timeout."
20290
22387
  }
20291
22388
  ],
20292
22389
  "subcomponents": [
@@ -20361,6 +22458,12 @@
20361
22458
  "part": "ToastRoot (stacked)",
20362
22459
  "why": "A stacked toast arrives from the anchored edge in front of the others, which step back and shrink to make room; hovering fans the deck out into the list. Moving every card, rather than snapping them, shows that the new one went on top and the old ones are still there."
20363
22460
  },
22461
+ {
22462
+ "slot": "response",
22463
+ "recipe": "motion-bloom",
22464
+ "part": "Status glyph",
22465
+ "why": "The glyph is what says how it went, so it lands as its own beat: it blooms in 120ms after the card, and blooms again in the spinner's place when a loading toast settles. A 16px glyph needs that amplitude to read as an answer; the card itself only slides."
22466
+ },
20364
22467
  {
20365
22468
  "slot": "activity",
20366
22469
  "recipe": "none",
@@ -20489,7 +22592,7 @@
20489
22592
  "name": "appearance",
20490
22593
  "type": "\"default\" | \"segmented\"",
20491
22594
  "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."
22595
+ "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
22596
  },
20494
22597
  {
20495
22598
  "name": "multiple",
@@ -20497,6 +22600,12 @@
20497
22600
  "default": "false",
20498
22601
  "description": "On ToggleGroup: allow several toggles pressed at once. The default is single-select, which behaves like a radio group."
20499
22602
  },
22603
+ {
22604
+ "name": "motion",
22605
+ "type": "\"full\" | \"subtle\" | \"off\"",
22606
+ "default": "\"full\"",
22607
+ "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."
22608
+ },
20500
22609
  {
20501
22610
  "name": "Toggle",
20502
22611
  "type": "Base UI Toggle props (pressed, defaultPressed, onPressedChange, disabled, value) + className",
@@ -20507,7 +22616,7 @@
20507
22616
  "subcomponents": [
20508
22617
  {
20509
22618
  "name": "ToggleGroup",
20510
- "type": "Base UI ToggleGroup props (value, defaultValue, onValueChange, multiple, disabled, orientation, loopFocus) + appearance?: \"default\" | \"segmented\" + className",
22619
+ "type": "Base UI ToggleGroup props (value, defaultValue, onValueChange, multiple, disabled, orientation, loopFocus) + appearance?: \"default\" | \"segmented\" + motion?: \"full\" | \"subtle\" | \"off\" + className",
20511
22620
  "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
22621
  },
20513
22622
  {
@@ -20531,6 +22640,38 @@
20531
22640
  "border",
20532
22641
  "ring"
20533
22642
  ],
22643
+ "motion": [
22644
+ {
22645
+ "slot": "arrival",
22646
+ "recipe": "none",
22647
+ "part": "Toggle",
22648
+ "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."
22649
+ },
22650
+ {
22651
+ "slot": "response",
22652
+ "recipe": "motion-interactive",
22653
+ "part": "Toggle",
22654
+ "why": "Hover and the pressed fill ease rather than flip, so pressing reads as the same button changing state, not a different button appearing."
22655
+ },
22656
+ {
22657
+ "slot": "response",
22658
+ "recipe": "motion-press",
22659
+ "part": "Toggle",
22660
+ "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."
22661
+ },
22662
+ {
22663
+ "slot": "transition",
22664
+ "recipe": "motion-glide",
22665
+ "part": "Segmented fill",
22666
+ "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."
22667
+ },
22668
+ {
22669
+ "slot": "activity",
22670
+ "recipe": "none",
22671
+ "part": "Toggle",
22672
+ "why": "A toggle commits instantly. Work that follows from the new state shows where its result appears, not on the control."
22673
+ }
22674
+ ],
20534
22675
  "a11y": [
20535
22676
  "Base UI sets aria-pressed on each Toggle and manages the pressed/disabled states.",
20536
22677
  "ToggleGroup provides arrow-key roving focus across its items; loopFocus wraps at the ends.",
@@ -20553,6 +22694,11 @@
20553
22694
  "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
22695
  "imports": "import { Icon, Toggle, ToggleGroup } from \"@sableui/react\""
20555
22696
  },
22697
+ {
22698
+ "label": "Segmented with labels, subtle motion",
22699
+ "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>",
22700
+ "imports": "import { Toggle, ToggleGroup } from \"@sableui/react\""
22701
+ },
20556
22702
  {
20557
22703
  "label": "Multi-select group",
20558
22704
  "code": "<ToggleGroup multiple defaultValue={[\"bold\"]}><Toggle value=\"bold\">Bold</Toggle><Toggle value=\"italic\">Italic</Toggle></ToggleGroup>",
@@ -20815,6 +22961,235 @@
20815
22961
  }
20816
22962
  ]
20817
22963
  },
22964
+ {
22965
+ "name": "ToolCall",
22966
+ "status": "stable",
22967
+ "group": "Chat & AI",
22968
+ "register": "either",
22969
+ "path": "components/tool-call",
22970
+ "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.",
22971
+ "keywords": [
22972
+ "tool call",
22973
+ "function call",
22974
+ "tool use",
22975
+ "agent step",
22976
+ "mcp",
22977
+ "ai",
22978
+ "tool result",
22979
+ "agent activity"
22980
+ ],
22981
+ "usage": {
22982
+ "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.",
22983
+ "bestPractices": [
22984
+ {
22985
+ "guidance": "do",
22986
+ "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."
22987
+ },
22988
+ {
22989
+ "guidance": "do",
22990
+ "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."
22991
+ },
22992
+ {
22993
+ "guidance": "do",
22994
+ "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."
22995
+ },
22996
+ {
22997
+ "guidance": "do",
22998
+ "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."
22999
+ },
23000
+ {
23001
+ "guidance": "dont",
23002
+ "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."
23003
+ },
23004
+ {
23005
+ "guidance": "dont",
23006
+ "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.",
23007
+ "instead": [
23008
+ "ThinkingOrb",
23009
+ "Spinner"
23010
+ ]
23011
+ },
23012
+ {
23013
+ "guidance": "dont",
23014
+ "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."
23015
+ }
23016
+ ],
23017
+ "anatomy": [
23018
+ {
23019
+ "name": "Status glyph",
23020
+ "required": true,
23021
+ "description": "Spinner while running, then the outcome: check, warning, clock (awaiting approval) or ×. The only part that moves."
23022
+ },
23023
+ {
23024
+ "name": "Title",
23025
+ "required": false,
23026
+ "description": "What the call does, in plain words. Without one, the tool's name leads."
23027
+ },
23028
+ {
23029
+ "name": "Name",
23030
+ "required": true,
23031
+ "description": "The tool's own name in mono, after the title. Hidden below the sm breakpoint when a title is present."
23032
+ },
23033
+ {
23034
+ "name": "Status",
23035
+ "required": true,
23036
+ "description": "One word for where the call is: Running, Needs approval, Done, Failed, Denied."
23037
+ },
23038
+ {
23039
+ "name": "Error line",
23040
+ "required": false,
23041
+ "description": "The failure message, under the row and always visible."
23042
+ },
23043
+ {
23044
+ "name": "Details",
23045
+ "required": false,
23046
+ "description": "Input and output as code, or a rendered result. A disclosure; the row is its trigger."
23047
+ }
23048
+ ]
23049
+ },
23050
+ "related": {
23051
+ "components": [
23052
+ "Message",
23053
+ "ThinkingOrb",
23054
+ "StreamingText",
23055
+ "CodeBlock",
23056
+ "Collapsible",
23057
+ "InlineStatus"
23058
+ ]
23059
+ },
23060
+ "props": [
23061
+ {
23062
+ "name": "name",
23063
+ "type": "string",
23064
+ "description": "The tool's own name, as the model called it (`search_docs`). Shown in mono."
23065
+ },
23066
+ {
23067
+ "name": "title",
23068
+ "type": "ReactNode",
23069
+ "description": "What the call does, in words a person reads. Leads the row; omit it and `name` leads."
23070
+ },
23071
+ {
23072
+ "name": "status",
23073
+ "type": "\"running\" | \"awaiting\" | \"complete\" | \"error\" | \"denied\"",
23074
+ "default": "\"running\"",
23075
+ "description": "Where the call is. `awaiting` is held for the person's approval; `denied` is one they declined."
23076
+ },
23077
+ {
23078
+ "name": "statusLabel",
23079
+ "type": "string",
23080
+ "description": "Overrides the status word, for another language or a more specific phrase."
23081
+ },
23082
+ {
23083
+ "name": "input",
23084
+ "type": "unknown",
23085
+ "description": "The arguments. A string shows as is; anything else as formatted JSON."
23086
+ },
23087
+ {
23088
+ "name": "output",
23089
+ "type": "unknown",
23090
+ "description": "The result, shown the same way as `input`."
23091
+ },
23092
+ {
23093
+ "name": "error",
23094
+ "type": "ReactNode",
23095
+ "description": "What went wrong, for `status=\"error\"`. Always visible under the row."
23096
+ },
23097
+ {
23098
+ "name": "children",
23099
+ "type": "ReactNode",
23100
+ "description": "A rendered result in place of `output`'s code view."
23101
+ },
23102
+ {
23103
+ "name": "defaultOpen",
23104
+ "type": "boolean",
23105
+ "default": "false",
23106
+ "description": "Open the details on mount."
23107
+ },
23108
+ {
23109
+ "name": "open",
23110
+ "type": "boolean",
23111
+ "description": "Controlled open state of the details."
23112
+ },
23113
+ {
23114
+ "name": "onOpenChange",
23115
+ "type": "(open: boolean) => void",
23116
+ "description": "Called when the details open or close."
23117
+ }
23118
+ ],
23119
+ "variants": {
23120
+ "status": [
23121
+ "running",
23122
+ "awaiting",
23123
+ "complete",
23124
+ "error",
23125
+ "denied"
23126
+ ]
23127
+ },
23128
+ "tokensUsed": [
23129
+ "card",
23130
+ "border",
23131
+ "destructive",
23132
+ "muted",
23133
+ "muted-foreground",
23134
+ "foreground",
23135
+ "tone-warning",
23136
+ "ring"
23137
+ ],
23138
+ "motion": [
23139
+ {
23140
+ "slot": "arrival",
23141
+ "recipe": "none",
23142
+ "part": "Root",
23143
+ "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."
23144
+ },
23145
+ {
23146
+ "slot": "response",
23147
+ "recipe": "motion-interactive",
23148
+ "part": "Header",
23149
+ "why": "The header is the disclosure trigger but reads as a status row. The hover step is what tells the person it opens."
23150
+ },
23151
+ {
23152
+ "slot": "transition",
23153
+ "recipe": "motion-icon-swap",
23154
+ "part": "Status glyph",
23155
+ "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."
23156
+ },
23157
+ {
23158
+ "slot": "transition",
23159
+ "recipe": "motion-disclosure",
23160
+ "part": "Details",
23161
+ "why": "The height change connects the code that appears to the row that was pressed, in a thread where several rows look alike."
23162
+ },
23163
+ {
23164
+ "slot": "activity",
23165
+ "recipe": "motion-spin",
23166
+ "part": "Status glyph",
23167
+ "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."
23168
+ }
23169
+ ],
23170
+ "a11y": [
23171
+ "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.",
23172
+ "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.",
23173
+ "The glyph is aria-hidden: the status word beside it carries the meaning, so colour and shape are never the only signal."
23174
+ ],
23175
+ "examples": [
23176
+ {
23177
+ "label": "A finished call",
23178
+ "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/>",
23179
+ "imports": "import { ToolCall } from \"@sableui/react\""
23180
+ },
23181
+ {
23182
+ "label": "Running, then failed",
23183
+ "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>",
23184
+ "imports": "import { ToolCall } from \"@sableui/react\""
23185
+ },
23186
+ {
23187
+ "label": "Waiting for approval",
23188
+ "code": "<ToolCall name=\"send_email\" title=\"Send the invoice to Ada\" status=\"awaiting\" input={{ to: \"ada@example.com\", subject: \"INV-1042\" }} />",
23189
+ "imports": "import { ToolCall } from \"@sableui/react\""
23190
+ }
23191
+ ]
23192
+ },
20818
23193
  {
20819
23194
  "name": "Tooltip",
20820
23195
  "status": "stable",
@@ -20923,7 +23298,12 @@
20923
23298
  {
20924
23299
  "name": "SimpleTooltip",
20925
23300
  "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."
23301
+ "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."
23302
+ },
23303
+ {
23304
+ "name": "TooltipGroup",
23305
+ "type": "{ children: ReactNode; motion?: \"full\" | \"subtle\" | \"off\" }",
23306
+ "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
23307
  }
20928
23308
  ],
20929
23309
  "tokensUsed": [
@@ -20955,6 +23335,12 @@
20955
23335
  "recipe": "none",
20956
23336
  "part": "Content",
20957
23337
  "why": "Tooltip copy is resident before the tooltip opens. Anything that has to be fetched is a Popover with its own loading state."
23338
+ },
23339
+ {
23340
+ "slot": "transition",
23341
+ "recipe": "custom",
23342
+ "part": "TooltipGroup",
23343
+ "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
23344
  }
20959
23345
  ],
20960
23346
  "a11y": [
@@ -20978,6 +23364,11 @@
20978
23364
  "label": "Shorthand (SimpleTooltip)",
20979
23365
  "code": "<SimpleTooltip content=\"Saved to your workspace.\"><Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Info\"><Icon name=\"info\" /></Button></SimpleTooltip>",
20980
23366
  "imports": "import { Button, Icon, SimpleTooltip } from \"@sableui/react\""
23367
+ },
23368
+ {
23369
+ "label": "One tooltip gliding along a toolbar (TooltipGroup)",
23370
+ "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>",
23371
+ "imports": "import { Icon, SimpleTooltip, Toolbar, ToolbarButton, TooltipGroup } from \"@sableui/react\""
20981
23372
  }
20982
23373
  ]
20983
23374
  },
@@ -21158,6 +23549,12 @@
21158
23549
  "name": "label",
21159
23550
  "type": "ReactNode",
21160
23551
  "description": "TreeAddRow's visible label. Name what gets added — 'Add effect', not 'Add'."
23552
+ },
23553
+ {
23554
+ "name": "motion",
23555
+ "type": "\"full\" | \"subtle\" | \"off\"",
23556
+ "default": "\"full\"",
23557
+ "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
23558
  }
21162
23559
  ],
21163
23560
  "subcomponents": [
@@ -21187,14 +23584,40 @@
21187
23584
  "description": "The left inset for a depth, as a calc() expression reading the density ladder. Use this rather than computing pixels."
21188
23585
  }
21189
23586
  ],
21190
- "tokensUsed": [
21191
- "panel-raised",
21192
- "hairline",
21193
- "foreground",
21194
- "muted-foreground",
21195
- "primary",
21196
- "ring"
21197
- ],
23587
+ "tokensUsed": [
23588
+ "panel-raised",
23589
+ "hairline",
23590
+ "foreground",
23591
+ "muted-foreground",
23592
+ "primary",
23593
+ "ring"
23594
+ ],
23595
+ "motion": [
23596
+ {
23597
+ "slot": "arrival",
23598
+ "recipe": "motion-arrive",
23599
+ "part": "TreeRow",
23600
+ "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."
23601
+ },
23602
+ {
23603
+ "slot": "response",
23604
+ "recipe": "motion-interactive",
23605
+ "part": "TreeRow",
23606
+ "why": "Hover and selection fills ease, so sweeping the pointer down a dense panel reads as a highlight following it rather than rows flickering."
23607
+ },
23608
+ {
23609
+ "slot": "transition",
23610
+ "recipe": "motion-interactive",
23611
+ "part": "Disclosure",
23612
+ "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."
23613
+ },
23614
+ {
23615
+ "slot": "activity",
23616
+ "recipe": "none",
23617
+ "part": "Tree",
23618
+ "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."
23619
+ }
23620
+ ],
21198
23621
  "a11y": [
21199
23622
  "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
23623
  "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 +23732,38 @@
21309
23732
  "overlay-foreground",
21310
23733
  "ring"
21311
23734
  ],
23735
+ "motion": [
23736
+ {
23737
+ "slot": "arrival",
23738
+ "recipe": "none",
23739
+ "part": "Root",
23740
+ "why": "The frame arrives with its page. Once there is a source, the poster and controls are the browser's."
23741
+ },
23742
+ {
23743
+ "slot": "response",
23744
+ "recipe": "motion-interactive",
23745
+ "part": "Play button",
23746
+ "why": "With `onRequestPlay`, the play disc firms its scrim on hover, eased, so the mark reads as pressable over any image."
23747
+ },
23748
+ {
23749
+ "slot": "response",
23750
+ "recipe": "motion-press",
23751
+ "part": "Play button",
23752
+ "why": "Loading a source can take a moment; the settle on press confirms the tap before the player appears."
23753
+ },
23754
+ {
23755
+ "slot": "transition",
23756
+ "recipe": "none",
23757
+ "part": "Root",
23758
+ "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."
23759
+ },
23760
+ {
23761
+ "slot": "activity",
23762
+ "recipe": "none",
23763
+ "part": "Root",
23764
+ "why": "Buffering belongs to the native controls, which draw their own indicator. A second loader over the frame would compete with the browser's."
23765
+ }
23766
+ ],
21312
23767
  "a11y": [
21313
23768
  "Mounts a native <video> element, preserving built-in keyboard controls when controls is enabled.",
21314
23769
  "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 +24197,31 @@
21742
24197
  "imports": "import { Button, Card, CardAction, CardDescription, CardHeader, CardList, CardTitle, cn, Code, Entity, Icon, MenuItem, MenuSeparator, OverflowMenu } from \"@sableui/react\"",
21743
24198
  "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
24199
  },
24200
+ {
24201
+ "name": "ApprovalBlock",
24202
+ "path": "blocks/approval-block.tsx",
24203
+ "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.",
24204
+ "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.",
24205
+ "family": "AI & agents",
24206
+ "kind": "card",
24207
+ "width": {
24208
+ "min": "18rem",
24209
+ "max": "28rem"
24210
+ },
24211
+ "notes": [
24212
+ "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.",
24213
+ "The description says what can't be undone. That sentence is what the person is deciding on.",
24214
+ "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.",
24215
+ "After a decision the buttons give way to one InlineStatus line saying what happened, so the thread keeps a record of the answer."
24216
+ ],
24217
+ "a11y": [
24218
+ "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.",
24219
+ "The details are a dl, so each value is read with its label.",
24220
+ "The remember Checkbox has its own Label, so the tool name inside it is part of its accessible name."
24221
+ ],
24222
+ "imports": "import { useState, useEffect, useRef, useId } from \"react\"\nimport { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, cn, Code, InlineStatus, Label } from \"@sableui/react\"",
24223
+ "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}"
24224
+ },
21745
24225
  {
21746
24226
  "name": "ChatThreadBlock",
21747
24227
  "path": "blocks/chat-thread-block.tsx",
@@ -22067,6 +24547,31 @@
22067
24547
  "imports": "import { useState } from \"react\"\nimport { Button, Card, CardContent, cn, Entity, Icon, MediaImage, Slider } from \"@sableui/react\"",
22068
24548
  "code": "// `cover` is the artwork URL; left empty, the cover paints art seeded by the title.\nconst track = { title: \"Low tide, high noon\", artist: \"Juno Avery\", cover: \"\", duration: 220, position: 84 }\n\n/** Seconds → m:ss, the way the readout and the screen reader both say it. */\nfunction formatTime(seconds: number) {\n\treturn `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, \"0\")}`\n}\n\n\nexport interface MediaPlayerBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a Sheet or muted section that already frames it. */\n\tframed?: boolean\n\t/** Called with the new playing state. */\n\tonPlayingChange?: (playing: boolean) => void\n\tonPrevious?: () => void\n\tonNext?: () => void\n}\n\nexport function MediaPlayerBlock({ className, framed = true, onPlayingChange, onPrevious, onNext }: MediaPlayerBlockProps) {\n\tconst [playing, setPlaying] = useState(true)\n\tconst [position, setPosition] = useState(track.position)\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-xs\", className)}>\n\t\t\t<CardContent className=\"flex flex-col gap-4\">\n\t\t\t\t{/* Entity's own row padding is for lists; inside a card's content the\n\t\t\t\t card already pads it. */}\n\t\t\t\t<Entity\n\t\t\t\t\tclassName=\"p-0\"\n\t\t\t\t\tleading={<MediaImage src={track.cover} seed={track.title} className=\"size-14 shrink-0 rounded-md\" />}\n\t\t\t\t\ttitle={track.title}\n\t\t\t\t\tsubtitle={track.artist}\n\t\t\t\t\taction={\n\t\t\t\t\t\t<Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Like\">\n\t\t\t\t\t\t\t<Icon name=\"heart\" />\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t<div className=\"flex flex-col gap-1.5\">\n\t\t\t\t\t<Slider\n\t\t\t\t\t\tvalue={position}\n\t\t\t\t\t\tmax={track.duration}\n\t\t\t\t\t\tonValueChange={setPosition}\n\t\t\t\t\t\taria-label=\"Position\"\n\t\t\t\t\t\t// Spoken as a time, not \"38\": the number alone means nothing.\n\t\t\t\t\t\tgetAriaValueText={(_, value) => `${formatTime(value)} of ${formatTime(track.duration)}`}\n\t\t\t\t\t/>\n\t\t\t\t\t<div className=\"flex justify-between text-mono-caption text-muted-foreground\">\n\t\t\t\t\t\t<span>{formatTime(position)}</span>\n\t\t\t\t\t\t<span>-{formatTime(track.duration - position)}</span>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex items-center justify-center gap-3\">\n\t\t\t\t\t<Button variant=\"ghost\" size=\"icon\" aria-label=\"Previous\" onClick={onPrevious}>\n\t\t\t\t\t\t<Icon name=\"previous\" variant=\"solid\" />\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\taria-label={playing ? \"Pause\" : \"Play\"}\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tsetPlaying(!playing)\n\t\t\t\t\t\t\tonPlayingChange?.(!playing)\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Icon name={playing ? \"pause\" : \"play\"} variant=\"solid\" />\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button variant=\"ghost\" size=\"icon\" aria-label=\"Next\" onClick={onNext}>\n\t\t\t\t\t\t<Icon name=\"next\" variant=\"solid\" />\n\t\t\t\t\t</Button>\n\t\t\t\t</div>\n\t\t\t</CardContent>\n\t\t</Card>\n\t)\n}"
22069
24549
  },
24550
+ {
24551
+ "name": "MessageReactionsBlock",
24552
+ "path": "blocks/message-reactions-block.tsx",
24553
+ "summary": "A short thread you can react to: right-click or long-press a bubble for a menu with a row of quick reactions above Reply and Copy, or use the add-reaction pill under a message. A new reaction blooms in and sprays copies of its emoji.",
24554
+ "intent": "Reach for this when a conversation excerpt should be reactable — a team thread, a comment card, a support transcript. A read-only excerpt is ChatThreadBlock; a working chat with a composer and history is the ChatPanel page shell.",
24555
+ "family": "Social & messaging",
24556
+ "kind": "card",
24557
+ "width": {
24558
+ "min": "18rem",
24559
+ "max": "24rem"
24560
+ },
24561
+ "notes": [
24562
+ "Two ways in, on purpose: the right-click / long-press menu is fast once you know it is there, and the add-reaction pill is how a first-time reader finds reactions at all.",
24563
+ "The quick row in the menu is MenuReactions (checkbox menu items) and the one in the pill's popover is ReactionPicker (a toolbar), so each is reachable by the keyboard model of the surface it sits in.",
24564
+ "Only the reader's own reaction celebrates. Counts from other people roll quietly, and the reactions a page loads with never play.",
24565
+ "The add-reaction pill appears only on messages that already have reactions; a message with none is reacted to from its menu, which keeps quiet rows quiet."
24566
+ ],
24567
+ "a11y": [
24568
+ "Each bubble is focusable, and its menu opens on right click, long press, Shift+F10 or the Menu key; its reactions are checkbox items reporting whether the reader has used each one.",
24569
+ "Each reaction pill is a toggle named by its meaning and count (\"Party, 3\") with `aria-pressed`; the emoji and the burst are hidden from assistive tech.",
24570
+ "Under reduced motion the reaction appears and the count changes at once; nothing else is lost."
24571
+ ],
24572
+ "imports": "import { Bubble, BubbleReaction, BubbleReactionAdd, BubbleReactions, Card, CardContent, CardFooter, cn, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, DEFAULT_REACTIONS, MenuReactions, Message } from \"@sableui/react\"",
24573
+ "code": "const messages: { id: string; text: string; mine: boolean; reactions: Record<string, number> }[] = [\n\t{ id: \"m1\", text: \"The new onboarding flow is live on staging 🚀\", mine: false, reactions: { \"🎉\": 2, \"👀\": 1 } },\n\t{ id: \"m2\", text: \"Clicked through it twice. The progress bar is so smooth.\", mine: true, reactions: {} },\n\t{ id: \"m3\", text: \"Ship it Friday?\", mine: false, reactions: { \"👍\": 1 } },\n]\nconst hint = \"Right-click or long-press a message to react.\"\n// Every emoji that can appear needs a name: it is the pill's accessible label.\nconst labels: Record<string, string> = {\n\t...Object.fromEntries(DEFAULT_REACTIONS.map((r) => [r.emoji, r.label])),\n\t\"👀\": \"Eyes\",\n}\n\nexport interface MessageReactionsBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border. Keep it framed on a muted page: incoming bubbles are the muted surface too. */\n\tframed?: boolean\n\t/** Called when the reader adds or removes a reaction, with the message, the emoji and whether it is now on. */\n\tonReact?: (messageId: string, emoji: string, on: boolean) => void\n}\n\nexport function MessageReactionsBlock({ className, framed = true, onReact }: MessageReactionsBlockProps) {\n\t// The reader's own reactions, per message. Everyone else's are the counts above.\n\tconst [mine, setMine] = useState<Record<string, string[]>>({ m1: [\"🎉\"] })\n\tconst toggle = (messageId: string, emoji: string) => {\n\t\tconst on = !(mine[messageId] ?? []).includes(emoji)\n\t\t// Decided again from `prev`, so two toggles in one batch cannot add an emoji twice.\n\t\tsetMine((prev) => {\n\t\t\tconst current = prev[messageId] ?? []\n\t\t\tconst next = current.includes(emoji) ? current.filter((e) => e !== emoji) : [...current, emoji]\n\t\t\treturn { ...prev, [messageId]: next }\n\t\t})\n\t\tonReact?.(messageId, emoji, on)\n\t}\n\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-sm\", className)}>\n\t\t\t<CardContent className=\"flex flex-col gap-3\">\n\t\t\t\t{messages.map((m) => {\n\t\t\t\t\tconst align = m.mine ? \"end\" : \"start\"\n\t\t\t\t\tconst chosen = mine[m.id] ?? []\n\t\t\t\t\t// Others' counts, plus the reader's own: a reaction only the reader gave\n\t\t\t\t\t// is a pill of its own, ordered after the ones already there.\n\t\t\t\t\tconst counts: Record<string, number> = { ...m.reactions }\n\t\t\t\t\tfor (const emoji of chosen) counts[emoji] = (counts[emoji] ?? 0)\n\t\t\t\t\tconst pills = Object.entries(counts).map(([emoji, others]) => ({\n\t\t\t\t\t\temoji,\n\t\t\t\t\t\tactive: chosen.includes(emoji),\n\t\t\t\t\t\tcount: others + (chosen.includes(emoji) ? 1 : 0),\n\t\t\t\t\t}))\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<Message key={m.id} messageId={m.id} align={m.mine ? \"end\" : undefined}>\n\t\t\t\t\t\t\t<ContextMenu>\n\t\t\t\t\t\t\t\t<ContextMenuTrigger\n\t\t\t\t\t\t\t\t\t// Focusable, so Shift+F10 or the Menu key opens the same menu.\n\t\t\t\t\t\t\t\t\trender={<Bubble tabIndex={0} variant={m.mine ? \"primary\" : undefined} align={align} />}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{m.text}\n\t\t\t\t\t\t\t\t</ContextMenuTrigger>\n\t\t\t\t\t\t\t\t<ContextMenuContent>\n\t\t\t\t\t\t\t\t\t<MenuReactions selected={chosen} onSelect={(emoji) => toggle(m.id, emoji)} />\n\t\t\t\t\t\t\t\t\t<ContextMenuSeparator />\n\t\t\t\t\t\t\t\t\t<ContextMenuItem>Reply</ContextMenuItem>\n\t\t\t\t\t\t\t\t\t<ContextMenuItem>Copy text</ContextMenuItem>\n\t\t\t\t\t\t\t\t</ContextMenuContent>\n\t\t\t\t\t\t\t</ContextMenu>\n\t\t\t\t\t\t\t{/* Rendered even while empty (it hides itself), so the first reaction still celebrates. */}\n\t\t\t\t\t\t\t<BubbleReactions align={align}>\n\t\t\t\t\t\t\t\t{pills.map((p) => (\n\t\t\t\t\t\t\t\t\t<BubbleReaction\n\t\t\t\t\t\t\t\t\t\tkey={p.emoji}\n\t\t\t\t\t\t\t\t\t\temoji={p.emoji}\n\t\t\t\t\t\t\t\t\t\tlabel={labels[p.emoji]}\n\t\t\t\t\t\t\t\t\t\tcount={p.count}\n\t\t\t\t\t\t\t\t\t\tactive={p.active}\n\t\t\t\t\t\t\t\t\t\tonClick={() => toggle(m.id, p.emoji)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t{pills.length > 0 ? (\n\t\t\t\t\t\t\t\t\t<BubbleReactionAdd selected={chosen} onSelect={(emoji) => toggle(m.id, emoji)} />\n\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t</BubbleReactions>\n\t\t\t\t\t\t</Message>\n\t\t\t\t\t)\n\t\t\t\t})}\n\t\t\t</CardContent>\n\t\t\t<CardFooter className=\"text-caption text-muted-foreground\">{hint}</CardFooter>\n\t\t</Card>\n\t)\n}"
24574
+ },
22070
24575
  {
22071
24576
  "name": "NewsletterBlock",
22072
24577
  "path": "blocks/newsletter-block.tsx",
@@ -23225,8 +25730,8 @@
23225
25730
  {
23226
25731
  "slug": "motion",
23227
25732
  "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",
25733
+ "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.",
25734
+ "headline": "4 slots · 3 speeds · 2 levels · enter-only overshoot · 1 kill switch",
23230
25735
  "retrieve": "sable docs motion",
23231
25736
  "vocabularies": [
23232
25737
  {
@@ -23287,7 +25792,47 @@
23287
25792
  {
23288
25793
  "name": "motion-icon-swap",
23289
25794
  "value": "enter · emphasized cross-fade",
23290
- "use": "Two icons of one control trading places: play/pause, outline/fill."
25795
+ "use": "Two icons of one control trading places: play/pause, outline/fill, copy/check."
25796
+ },
25797
+ {
25798
+ "name": "motion-glide",
25799
+ "value": "slow · emphasized · expressive",
25800
+ "use": "ONE indicator travelling to the selected option: tabs, segmented thumb, active dot."
25801
+ },
25802
+ {
25803
+ "name": "motion-shake",
25804
+ "value": "~400ms decaying · expressive",
25805
+ "use": "A refused answer: wrong code, invalid submit. Plays while data-shake is set (useShake). Never the only signal."
25806
+ },
25807
+ {
25808
+ "name": "motion-tick",
25809
+ "value": "fast fade + base wipe · expressive",
25810
+ "use": "A check mark that draws itself in. Exits only fade."
25811
+ },
25812
+ {
25813
+ "name": "motion-appear",
25814
+ "value": "fast fade + base scale",
25815
+ "use": "A small control mounting in place: a clear button, a send arrow, a badge. @starting-style, no JS."
25816
+ },
25817
+ {
25818
+ "name": "motion-arrive",
25819
+ "value": "base · decelerate · staggered · expressive",
25820
+ "use": "Items a user action brings into a list: a folder's children on expand, a row just added. Never on every render."
25821
+ },
25822
+ {
25823
+ "name": "motion-size",
25824
+ "value": "base · emphasized · expressive",
25825
+ "use": "An element easing between intrinsic sizes when its label changes: a two-step confirm, Copy → Copied."
25826
+ },
25827
+ {
25828
+ "name": "motion-bloom",
25829
+ "value": "slower · 0 → 1.2 → 1 · expressive",
25830
+ "use": "A small glyph landing in its on state: a liked heart, a picked rating, a toast's status icon. Remount it (key) to replay; never on the resting state."
25831
+ },
25832
+ {
25833
+ "name": "<Burst>",
25834
+ "value": "slower ring + thrown dots · expressive",
25835
+ "use": "The follow-through behind a bloom, in the theme's palette families. Only on a choice a person made: a like, a star, a finished task."
23291
25836
  },
23292
25837
  {
23293
25838
  "name": "motion-draw",
@@ -23331,6 +25876,42 @@
23331
25876
  }
23332
25877
  ]
23333
25878
  },
25879
+ {
25880
+ "title": "Two levels",
25881
+ "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.",
25882
+ "terms": [
25883
+ {
25884
+ "name": "essential",
25885
+ "value": "always, unless off",
25886
+ "use": "Colour and focus feedback, press, overlay arrival, disclosure height, loaders, chart hover."
25887
+ },
25888
+ {
25889
+ "name": "expressive",
25890
+ "value": "full only",
25891
+ "use": "Glides, rolling numbers (NumberTicker), drawn ticks, blooms and bursts, shakes, staggers, chart arrival and morph, scroll reveals, spring overshoot."
25892
+ },
25893
+ {
25894
+ "name": "full",
25895
+ "value": "default",
25896
+ "use": "Both levels play. The crafted default."
25897
+ },
25898
+ {
25899
+ "name": "subtle",
25900
+ "value": "data-motion=\"subtle\"",
25901
+ "use": "Essential only; nothing overshoots. For dense tools, embedded widgets, readers who want calm without stillness."
25902
+ },
25903
+ {
25904
+ "name": "off",
25905
+ "value": "data-motion=\"off\" · reduce-motion",
25906
+ "use": "Nothing moves; every state still changes."
25907
+ },
25908
+ {
25909
+ "name": "where",
25910
+ "value": "provider · scope · prop",
25911
+ "use": "<MotionProvider policy> for the app, <MotionScope level> for a region, `motion` on a component that owns a flourish."
25912
+ }
25913
+ ]
25914
+ },
23334
25915
  {
23335
25916
  "title": "The four slots",
23336
25917
  "note": "Every interactive component answers all four, by filling the slot or declining it with a reason in meta.motion.",
@@ -23389,6 +25970,34 @@
23389
25970
  {
23390
25971
  "rule": "JS motion reads useMotion().enabled and does nothing when it is false.",
23391
25972
  "why": "Reduced motion is an accessibility setting, not a style preference."
25973
+ },
25974
+ {
25975
+ "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.",
25976
+ "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."
25977
+ },
25978
+ {
25979
+ "rule": "Flourishes are expressive: multiply their durations by --motion-expressive and gate JS ones on useMotion().expressive.",
25980
+ "why": "`subtle` promises the answers without the choreography. A glide on a plain token keeps playing after someone asked for calm."
25981
+ },
25982
+ {
25983
+ "rule": "A state change finishes its story: refused shakes and says why, success confirms in place, armed asks again.",
25984
+ "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."
25985
+ },
25986
+ {
25987
+ "rule": "Amplitude scales with smallness. A 16px glyph that turns on grows from nothing past its size; a surface travels from 0.96 and a press settles to 0.97.",
25988
+ "why": "The same 4% that is right for a dialog is invisible on an icon, which is why a state change on a small control used to read as a colour swap with no life in it. A dialog that overshot 20% would lurch. Size, not importance, sets how far a thing moves."
25989
+ },
25990
+ {
25991
+ "rule": "Stage a moment: the main object lands first, the follow-through after it.",
25992
+ "why": "A ring that opens, a glyph that blooms 90ms later, dots thrown 60ms after that read as one event with a cause and an effect. Everything starting on the same frame reads as a flash. LikeButton and the toast's status glyph are the reference cases."
25993
+ },
25994
+ {
25995
+ "rule": "Celebrate only what a person chose, and only at full motion.",
25996
+ "why": "A burst is a reply. On a like it is delight; on a system event, on load, or on every row of a list it is a notification wearing confetti. Under subtle the fill alone answers."
25997
+ },
25998
+ {
25999
+ "rule": "Component motion props and MotionScope only turn motion down.",
26000
+ "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
26001
  }
23393
26002
  ],
23394
26003
  "noGos": [
@@ -24466,6 +27075,149 @@
24466
27075
  "motion-interactive"
24467
27076
  ]
24468
27077
  },
27078
+ {
27079
+ "name": "motion-glide",
27080
+ "kind": "utility",
27081
+ "family": "motion",
27082
+ "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.",
27083
+ "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.",
27084
+ "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.",
27085
+ "example": "<span aria-hidden className=\"motion-glide absolute bottom-0 h-0.5 bg-foreground\" style={{ left: x, width: w }} />",
27086
+ "keywords": [
27087
+ "sliding indicator",
27088
+ "tab indicator",
27089
+ "segmented thumb",
27090
+ "active indicator",
27091
+ "glide",
27092
+ "underline animation",
27093
+ "selection animation"
27094
+ ],
27095
+ "pairsWith": [
27096
+ "motion-interactive"
27097
+ ]
27098
+ },
27099
+ {
27100
+ "name": "motion-shake",
27101
+ "kind": "utility",
27102
+ "family": "motion",
27103
+ "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.",
27104
+ "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.",
27105
+ "insteadOf": "A keyframe shake with a literal duration, which keeps moving under reduced motion, or a shake with no error text.",
27106
+ "example": "const { shake, shakeProps } = useShake()\n<form className=\"motion-shake\" {...shakeProps} onSubmit={(e) => { if (!valid) { e.preventDefault(); shake() } }}>…</form>",
27107
+ "keywords": [
27108
+ "shake",
27109
+ "error animation",
27110
+ "wrong password",
27111
+ "invalid",
27112
+ "refuse",
27113
+ "wiggle",
27114
+ "nope"
27115
+ ],
27116
+ "pairsWith": [
27117
+ "motion-interactive"
27118
+ ]
27119
+ },
27120
+ {
27121
+ "name": "motion-tick",
27122
+ "kind": "utility",
27123
+ "family": "motion",
27124
+ "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.",
27125
+ "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.",
27126
+ "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.",
27127
+ "example": "<Base.Indicator className=\"motion-tick\"><Icon name=\"check\" /></Base.Indicator>",
27128
+ "keywords": [
27129
+ "check animation",
27130
+ "tick",
27131
+ "draw check",
27132
+ "success mark",
27133
+ "checkbox animation",
27134
+ "selection mark"
27135
+ ]
27136
+ },
27137
+ {
27138
+ "name": "motion-appear",
27139
+ "kind": "utility",
27140
+ "family": "motion",
27141
+ "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.",
27142
+ "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`.",
27143
+ "insteadOf": "Conditionally rendering a control so it pops in on one frame, or wrapping a single icon button in Presence.",
27144
+ "example": "{value && <span className=\"flex motion-appear\"><Button size=\"icon-xs\" variant=\"ghost\" aria-label=\"Clear\"><Icon name=\"xmark\" /></Button></span>}",
27145
+ "keywords": [
27146
+ "appear",
27147
+ "mount animation",
27148
+ "pop in",
27149
+ "enter",
27150
+ "starting style",
27151
+ "clear button"
27152
+ ],
27153
+ "pairsWith": [
27154
+ "motion-icon-swap"
27155
+ ]
27156
+ },
27157
+ {
27158
+ "name": "motion-arrive",
27159
+ "kind": "utility",
27160
+ "family": "motion",
27161
+ "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.",
27162
+ "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.",
27163
+ "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.",
27164
+ "example": "<li className=\"motion-arrive\" style={{ \"--motion-arrive-delay\": \"36ms\" } as CSSProperties}>{row.name}</li>",
27165
+ "keywords": [
27166
+ "stagger",
27167
+ "list animation",
27168
+ "arrival",
27169
+ "cascade",
27170
+ "new item",
27171
+ "expand children",
27172
+ "enter list"
27173
+ ],
27174
+ "pairsWith": [
27175
+ "motion-interactive"
27176
+ ]
27177
+ },
27178
+ {
27179
+ "name": "motion-bloom",
27180
+ "kind": "utility",
27181
+ "family": "motion",
27182
+ "intent": "A small glyph landing in its \"on\" state grows from nothing past its size (1.2) and settles. The amplitude a 16px glyph needs to read as an event; surfaces never bloom. A keyframe that plays on mount, so remount the glyph (a changing `key`) to replay it. `--motion-bloom-delay` holds it at nothing first (raw ms; the recipe scales it). Expressive.",
27183
+ "whenToUse": "A glyph that a person's choice turns on: a liked heart, a starred item, a status icon arriving in a toast. LikeButton and Toast already use it. Pair with <Burst> for the follow-through on a celebratory toggle.",
27184
+ "insteadOf": "A bigger motion-press or motion-pop on the whole control (a button or card that overshoots 20% lurches), or a hand-written scale keyframe with literal durations. Never on the resting state: a page that loads with a post already liked must not bloom every heart.",
27185
+ "example": "<span key={likes} className={liked ? \"motion-bloom\" : undefined}><Icon name=\"heart\" variant={liked ? \"solid\" : \"regular\"} /></span>",
27186
+ "keywords": [
27187
+ "pop",
27188
+ "overshoot",
27189
+ "like",
27190
+ "heart",
27191
+ "star",
27192
+ "celebrate",
27193
+ "glyph",
27194
+ "icon pop",
27195
+ "delight"
27196
+ ],
27197
+ "pairsWith": [
27198
+ "motion-interactive"
27199
+ ]
27200
+ },
27201
+ {
27202
+ "name": "motion-size",
27203
+ "kind": "utility",
27204
+ "family": "motion",
27205
+ "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.",
27206
+ "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.",
27207
+ "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.",
27208
+ "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>",
27209
+ "keywords": [
27210
+ "width animation",
27211
+ "auto width",
27212
+ "label change",
27213
+ "resize animation",
27214
+ "interpolate size",
27215
+ "morph button"
27216
+ ],
27217
+ "pairsWith": [
27218
+ "motion-icon-swap"
27219
+ ]
27220
+ },
24469
27221
  {
24470
27222
  "name": "motion-spin",
24471
27223
  "kind": "utility",
@@ -24951,6 +27703,7 @@
24951
27703
  "label",
24952
27704
  "legend",
24953
27705
  "locale",
27706
+ "motion",
24954
27707
  "onActiveIndexChange",
24955
27708
  "series",
24956
27709
  "stacked",
@@ -25055,6 +27808,7 @@
25055
27808
  "labels",
25056
27809
  "legend",
25057
27810
  "locale",
27811
+ "motion",
25058
27812
  "negativeTone",
25059
27813
  "onActiveIndexChange",
25060
27814
  "orientation",
@@ -25091,11 +27845,29 @@
25091
27845
  "BubbleReaction": [
25092
27846
  "active",
25093
27847
  "count",
25094
- "emoji"
27848
+ "emoji",
27849
+ "label",
27850
+ "motion"
27851
+ ],
27852
+ "BubbleReactionAdd": [
27853
+ "label",
27854
+ "motion",
27855
+ "onSelect",
27856
+ "reactions",
27857
+ "selected"
25095
27858
  ],
25096
27859
  "BubbleReactions": [
25097
27860
  "align"
25098
27861
  ],
27862
+ "Burst": [
27863
+ "className",
27864
+ "count",
27865
+ "motion",
27866
+ "palette",
27867
+ "particle",
27868
+ "playOnMount",
27869
+ "trigger"
27870
+ ],
25099
27871
  "Button": [
25100
27872
  "badge",
25101
27873
  "bleed",
@@ -25149,6 +27921,7 @@
25149
27921
  "modifiersClassNames",
25150
27922
  "modifiersStyles",
25151
27923
  "month",
27924
+ "motion",
25152
27925
  "navLayout",
25153
27926
  "nonce",
25154
27927
  "noonSafe",
@@ -25236,6 +28009,7 @@
25236
28009
  "index",
25237
28010
  "label",
25238
28011
  "loop",
28012
+ "motion",
25239
28013
  "onIndexChange",
25240
28014
  "slideClassName",
25241
28015
  "slidesToShow"
@@ -25255,6 +28029,7 @@
25255
28029
  "invalid",
25256
28030
  "label",
25257
28031
  "leading",
28032
+ "motion",
25258
28033
  "onAttach",
25259
28034
  "onSend",
25260
28035
  "onStop",
@@ -25274,6 +28049,7 @@
25274
28049
  "id",
25275
28050
  "indeterminate",
25276
28051
  "inputRef",
28052
+ "motion",
25277
28053
  "name",
25278
28054
  "nativeButton",
25279
28055
  "onCheckedChange",
@@ -25296,6 +28072,7 @@
25296
28072
  "id",
25297
28073
  "indeterminate",
25298
28074
  "inputRef",
28075
+ "motion",
25299
28076
  "name",
25300
28077
  "nativeButton",
25301
28078
  "onCheckedChange",
@@ -25556,6 +28333,21 @@
25556
28333
  "shortcut",
25557
28334
  "size"
25558
28335
  ],
28336
+ "ConfirmButton": [
28337
+ "badge",
28338
+ "bleed",
28339
+ "children",
28340
+ "confirmHint",
28341
+ "confirmLabel",
28342
+ "confirmVariant",
28343
+ "icon",
28344
+ "iconEnd",
28345
+ "motion",
28346
+ "onConfirm",
28347
+ "size",
28348
+ "timeout",
28349
+ "variant"
28350
+ ],
25559
28351
  "ContextCard": [
25560
28352
  "actionsRef",
25561
28353
  "children",
@@ -25852,10 +28644,12 @@
25852
28644
  "Feedback": [
25853
28645
  "heading",
25854
28646
  "label",
28647
+ "motion",
25855
28648
  "onSubmit",
25856
28649
  "placeholder",
25857
28650
  "ratings",
25858
- "submitLabel"
28651
+ "submitLabel",
28652
+ "successMessage"
25859
28653
  ],
25860
28654
  "Field": [
25861
28655
  "children",
@@ -25991,6 +28785,21 @@
25991
28785
  "hint",
25992
28786
  "size"
25993
28787
  ],
28788
+ "LikeButton": [
28789
+ "bleed",
28790
+ "count",
28791
+ "defaultPressed",
28792
+ "format",
28793
+ "icon",
28794
+ "label",
28795
+ "motion",
28796
+ "onClick",
28797
+ "onPressedChange",
28798
+ "pressed",
28799
+ "size",
28800
+ "tone",
28801
+ "variant"
28802
+ ],
25994
28803
  "LineChart": [
25995
28804
  "activeIndex",
25996
28805
  "animate",
@@ -26007,6 +28816,7 @@
26007
28816
  "labels",
26008
28817
  "legend",
26009
28818
  "locale",
28819
+ "motion",
26010
28820
  "onActiveIndexChange",
26011
28821
  "series",
26012
28822
  "tooltip",
@@ -26191,6 +29001,15 @@
26191
29001
  "style",
26192
29002
  "value"
26193
29003
  ],
29004
+ "MenuReactions": [
29005
+ "className",
29006
+ "motion",
29007
+ "onSelect",
29008
+ "reactions",
29009
+ "render",
29010
+ "selected",
29011
+ "style"
29012
+ ],
26194
29013
  "MenuSeparator": [
26195
29014
  "className",
26196
29015
  "orientation",
@@ -26312,6 +29131,10 @@
26312
29131
  "policy",
26313
29132
  "target"
26314
29133
  ],
29134
+ "MotionScope": [
29135
+ "children",
29136
+ "level"
29137
+ ],
26315
29138
  "NavigationMenu": [
26316
29139
  "actionsRef",
26317
29140
  "align",
@@ -26368,6 +29191,7 @@
26368
29191
  "count",
26369
29192
  "dot",
26370
29193
  "max",
29194
+ "motion",
26371
29195
  "overlay",
26372
29196
  "size",
26373
29197
  "tone"
@@ -26398,6 +29222,15 @@
26398
29222
  "style",
26399
29223
  "value"
26400
29224
  ],
29225
+ "NumberTicker": [
29226
+ "format",
29227
+ "locales",
29228
+ "motion",
29229
+ "prefix",
29230
+ "suffix",
29231
+ "trend",
29232
+ "value"
29233
+ ],
26401
29234
  "OtpField": [
26402
29235
  "aria-invalid",
26403
29236
  "autoComplete",
@@ -26412,6 +29245,7 @@
26412
29245
  "inputMode",
26413
29246
  "length",
26414
29247
  "mask",
29248
+ "motion",
26415
29249
  "name",
26416
29250
  "normalizeValue",
26417
29251
  "onValueChange",
@@ -26499,6 +29333,7 @@
26499
29333
  "labels",
26500
29334
  "legend",
26501
29335
  "locale",
29336
+ "motion",
26502
29337
  "onActiveIndexChange",
26503
29338
  "separator",
26504
29339
  "tooltip",
@@ -26655,6 +29490,7 @@
26655
29490
  "label",
26656
29491
  "legend",
26657
29492
  "locale",
29493
+ "motion",
26658
29494
  "onActiveIndexChange",
26659
29495
  "radiusAxis",
26660
29496
  "series",
@@ -26675,6 +29511,7 @@
26675
29511
  "legend",
26676
29512
  "locale",
26677
29513
  "max",
29514
+ "motion",
26678
29515
  "onActiveIndexChange",
26679
29516
  "stacked",
26680
29517
  "sweep",
@@ -26726,6 +29563,19 @@
26726
29563
  "style",
26727
29564
  "value"
26728
29565
  ],
29566
+ "ReactionPicker": [
29567
+ "children",
29568
+ "className",
29569
+ "disabled",
29570
+ "loopFocus",
29571
+ "motion",
29572
+ "onSelect",
29573
+ "orientation",
29574
+ "reactions",
29575
+ "render",
29576
+ "selected",
29577
+ "style"
29578
+ ],
26729
29579
  "Region": [
26730
29580
  "as",
26731
29581
  "bleed",
@@ -26999,6 +29849,7 @@
26999
29849
  "Sidebar": [
27000
29850
  "collapsed",
27001
29851
  "icons",
29852
+ "motion",
27002
29853
  "surface",
27003
29854
  "variant"
27004
29855
  ],
@@ -27016,6 +29867,7 @@
27016
29867
  ],
27017
29868
  "SideNav": [
27018
29869
  "activeVariant",
29870
+ "motion",
27019
29871
  "size"
27020
29872
  ],
27021
29873
  "SideNavGroup": [
@@ -27141,6 +29993,7 @@
27141
29993
  "data",
27142
29994
  "label",
27143
29995
  "locale",
29996
+ "motion",
27144
29997
  "tone",
27145
29998
  "variant"
27146
29999
  ],
@@ -27216,6 +30069,7 @@
27216
30069
  "Tabs": [
27217
30070
  "className",
27218
30071
  "defaultValue",
30072
+ "motion",
27219
30073
  "onValueChange",
27220
30074
  "orientation",
27221
30075
  "render",
@@ -27280,6 +30134,7 @@
27280
30134
  ],
27281
30135
  "ThemeSwitcher": [
27282
30136
  "label",
30137
+ "motion",
27283
30138
  "onValueChange",
27284
30139
  "value"
27285
30140
  ],
@@ -27357,6 +30212,7 @@
27357
30212
  "defaultValue",
27358
30213
  "disabled",
27359
30214
  "loopFocus",
30215
+ "motion",
27360
30216
  "multiple",
27361
30217
  "onValueChange",
27362
30218
  "orientation",
@@ -27420,6 +30276,19 @@
27420
30276
  "render",
27421
30277
  "style"
27422
30278
  ],
30279
+ "ToolCall": [
30280
+ "children",
30281
+ "defaultOpen",
30282
+ "error",
30283
+ "input",
30284
+ "name",
30285
+ "onOpenChange",
30286
+ "open",
30287
+ "output",
30288
+ "status",
30289
+ "statusLabel",
30290
+ "title"
30291
+ ],
27423
30292
  "Tooltip": [
27424
30293
  "actionsRef",
27425
30294
  "children",
@@ -27447,6 +30316,10 @@
27447
30316
  "sideOffset",
27448
30317
  "style"
27449
30318
  ],
30319
+ "TooltipGroup": [
30320
+ "children",
30321
+ "motion"
30322
+ ],
27450
30323
  "TooltipPortal": [
27451
30324
  "className",
27452
30325
  "container",
@@ -27471,6 +30344,9 @@
27471
30344
  "render",
27472
30345
  "style"
27473
30346
  ],
30347
+ "Tree": [
30348
+ "motion"
30349
+ ],
27474
30350
  "TreeAddRow": [
27475
30351
  "depth",
27476
30352
  "icon",
@@ -27615,19 +30491,19 @@
27615
30491
  },
27616
30492
  {
27617
30493
  "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.",
30494
+ "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
30495
  "required": "Optional. Without it, motion follows the visitor's OS `prefers-reduced-motion` setting, and useMotion() still works (its setPolicy does nothing).",
27620
30496
  "placement": "Once, at the app root, when the app has an in-app motion setting.",
27621
30497
  "props": [
27622
30498
  {
27623
30499
  "name": "defaultPolicy",
27624
- "type": "\"system\" | \"full\" | \"reduced\" | \"off\"",
30500
+ "type": "\"system\" | \"full\" | \"subtle\" | \"reduced\" | \"off\"",
27625
30501
  "default": "\"system\"",
27626
- "description": "Initial policy when uncontrolled. \"system\" defers to the OS preference."
30502
+ "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
30503
  },
27628
30504
  {
27629
30505
  "name": "policy",
27630
- "type": "\"system\" | \"full\" | \"reduced\" | \"off\"",
30506
+ "type": "\"system\" | \"full\" | \"subtle\" | \"reduced\" | \"off\"",
27631
30507
  "description": "Controlled policy. Pair with onPolicyChange."
27632
30508
  },
27633
30509
  {
@@ -27644,13 +30520,17 @@
27644
30520
  ],
27645
30521
  "companions": [
27646
30522
  "useMotion",
27647
- "useReducedMotion"
30523
+ "useMotionLevel",
30524
+ "useReducedMotion",
30525
+ "MotionScope"
27648
30526
  ],
27649
30527
  "notes": [
27650
- "JavaScript motion must read useMotion().enabled and do nothing when it is false; CSS recipes stop on their own."
30528
+ "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.",
30529
+ "Mapping the app's \"full\" choice back to \"system\" keeps honouring the OS reduce-motion preference; \"full\" itself forces motion on."
27651
30530
  ],
27652
30531
  "related": [
27653
- "Switch"
30532
+ "Switch",
30533
+ "ToggleGroup"
27654
30534
  ],
27655
30535
  "import": "import { MotionProvider } from \"@sableui/react\"",
27656
30536
  "examples": [
@@ -27663,6 +30543,42 @@
27663
30543
  "label": "A reduce-motion setting",
27664
30544
  "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
30545
  "imports": "import { Switch, useMotion } from \"@sableui/react\""
30546
+ },
30547
+ {
30548
+ "label": "A three-way motion setting",
30549
+ "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}",
30550
+ "imports": "import { Toggle, ToggleGroup, useMotion } from \"@sableui/react\""
30551
+ }
30552
+ ]
30553
+ },
30554
+ {
30555
+ "name": "MotionScope",
30556
+ "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.",
30557
+ "required": "Optional. Reach for it when a whole region should be calmer than the app; for one component, use that component's `motion` prop.",
30558
+ "placement": "Around the region. It renders a `display: contents` element, so it adds no box to the layout.",
30559
+ "props": [
30560
+ {
30561
+ "name": "level",
30562
+ "type": "\"subtle\" | \"off\"",
30563
+ "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."
30564
+ }
30565
+ ],
30566
+ "companions": [
30567
+ "useMotionLevel",
30568
+ "MotionProvider"
30569
+ ],
30570
+ "notes": [
30571
+ "Overlays portal out of the DOM subtree, so their CSS follows the app-level policy; set it on MotionProvider to cover them too."
30572
+ ],
30573
+ "related": [
30574
+ "MotionProvider"
30575
+ ],
30576
+ "import": "import { MotionScope } from \"@sableui/react\"",
30577
+ "examples": [
30578
+ {
30579
+ "label": "A calm data view",
30580
+ "code": "export function Dashboard({ children }: { children: ReactNode }) {\n\treturn <MotionScope level=\"subtle\">{children}</MotionScope>\n}",
30581
+ "imports": "import type { ReactNode } from \"react\"\nimport { MotionScope } from \"@sableui/react\""
27666
30582
  }
27667
30583
  ]
27668
30584
  },