Compare commits

...

86 Commits

Author SHA1 Message Date
shadcn
dbf9c5ebc4 chore(deps): bump base-ui to 1.6.0 (#11059) 2026-06-30 10:33:51 +04:00
shadcn
897e9add14 feat(command-menu): add styles group linking to shadcn/create (#11052)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:30:46 +04:00
shadcn
8d6553a7f5 fix: orientation props on attachment (#11053) 2026-06-29 21:29:49 +04:00
shadcn
683073f102 feat(registry): add ai-elements components to new-york-v4 (#11051)
* feat(registry): add ai-elements components to new-york-v4

Adds attachment, bubble, marker, message, and message-scroller (registry:ui) to the new-york-v4 style, ported from radix-vega (also Radix-based, so non-breaking). Regenerates the runtime indexes (__index__.tsx, __components__.tsx) and the built catalog so they stay in sync.

* fix(registry): use resolved orientation for attachment variants

data-orientation used the normalized resolvedOrientation while the variant classes used the raw orientation prop, desyncing them when orientation is null. Use resolvedOrientation for both. Addresses Copilot review on #11051.
2026-06-29 20:56:50 +04:00
shadcn
02e398ab73 feat(skill): add chat component guidance to shadcn skill (#11048)
Add MessageScroller, Message, Bubble, Attachment, and Marker guidance,
driven by a baseline gap analysis: fresh agents hand-rolled all five
primitives, so this adds a rules/chat.md, discovery hooks in SKILL.md,
two evals, and supporting composition/styling rules.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:18:46 +04:00
shadcn
af79276f7e fix(v4): build @shadcn/react before registry build (#11049)
The v4 app imports @shadcn/react/message-scroller, which resolves via the
package's built dist. CI is a fresh clone and registry:build only built
shadcn, so the import was unresolved and the dev server never compiled,
timing out the test job. Build @shadcn/react first.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:54:41 +04:00
Arik Chakma
5a3ad36a5e fix(message-scroller): keep scrollbar gutter stable during autoscroll (#11032) 2026-06-29 16:32:21 +04:00
shrivi-maker
a63e8359ec feat(registry): add @channel3 to the registry directory (#11026)
Co-authored-by: Shrivathsan Sakthisundaram <285776207+shrivi-maker@users.noreply.github.com>
Co-authored-by: WOZCODE <contact@withwoz.com>
2026-06-29 15:24:17 +04:00
Tommaso
a72491cb9b Add evex to registry directory (#11023) 2026-06-27 13:14:17 +04:00
rds_agi
67aec7dcc5 fix(base): wrap dropdown label/items in group in message-scroller commands example (#11025)
DropdownMenuLabel renders Base UI's Menu.GroupLabel, which throws
"MenuGroupContext is missing" (Base UI error #31) unless rendered inside
a Menu.Group. Wrap the label and items in DropdownMenuGroup so the
"Jump to..." menu opens without crashing.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:13:58 +04:00
shadcn
40c7064532 docs: changelog 2026-06-26 22:18:01 +04:00
shadcn
5b3369f6ee fix: sidebar link 2026-06-26 21:49:48 +04:00
shadcn
b31e6d63b0 Merge branch 'main' of github.com:shadcn-ui/ui 2026-06-26 21:43:58 +04:00
shadcn
cf5b227565 docs: remove react page 2026-06-26 21:43:41 +04:00
github-actions[bot]
8055a12f46 chore(release): version packages (#11024)
Co-authored-by: shadcn <m@shadcn.com>
2026-06-26 21:25:48 +04:00
shadcn
18fcf0f766 feat: @shadcn/react (#11022)
* feat(@shadcn/react): add message-scroller package

Add the @shadcn/react headless primitives package with MessageScroller
scroll anchoring, streaming follow, history prepend, and jump-to-message
behavior. Includes geometry helpers, use-render utility, and unit,
browser, and perf tests.

* feat(registry): add chat components

Add MessageScroller, Message, Bubble, Attachment, and Marker registry
sources for base and radix, style variants, preview-03 chat blocks,
and registry index wiring.

* feat(v4): integrate chat components into docs site

Wire chat components into the v4 app with docs routes, example preview
pages, message part renderers, markdown support, registry build updates,
and supporting lib utilities.

* feat(examples): add chat component demos

Add base and radix example demos for MessageScroller, Message, Bubble,
Attachment, Marker, scroll-fade, and shimmer.

* docs: add chat component documentation

Add component and utility docs for the chat component set, update docs
navigation, and add the June 2026 chat components changelog entry.

* chore: regenerate registry JSON output

Rebuild public registry artifacts for all style variants with the new
chat components.

* chore(release): add @shadcn/react publish and CI pipeline

Add Changesets prerelease workflow, browser test job, RELEASING docs,
and monorepo wiring for publishing @shadcn/react independently from
the shadcn CLI.

* docs: fix display of component preview on mobile

* fix

* fix

* docs: add message scroller docs

* style: format

* fix
2026-06-26 21:19:31 +04:00
github-actions[bot]
c520191cd4 chore(release): version packages (#10949)
Co-authored-by: shadcn <m@shadcn.com>
2026-06-26 11:15:28 +04:00
Chai Pin Zheng
35983528c2 docs(registry): add @payload-components to directory (#11006)
Summary:
- Add the @payload-components namespace to the public registry directory.
- Include the live registry URL, homepage, description, and logo.

Validation:
- pnpm validate:registries
- curl checks for /r/registry.json and /r/hero-basic.json
2026-06-24 23:55:45 +04:00
shadcn
8692cd4cc1 fix: hookform zod errors (#11007) 2026-06-24 21:30:16 +04:00
shadcn
d3727d8c45 feat: add a /examples/[base]/[name] route 2026-06-24 20:49:27 +04:00
shadcn
e3f98d49e4 chore: update turborepo (#11005) 2026-06-24 20:30:09 +04:00
shadcn
549852bffc chore: update fumadocs (#11004) 2026-06-24 17:52:21 +04:00
Mohammad Shehadeh
4139cff3a0 feat(registry): add hirael to directory (#10965) 2026-06-24 17:35:08 +04:00
Denish Navadiya
95471a0fb9 feat: add @paceui in trusted registries (#10972) 2026-06-24 12:14:35 +04:00
Ray
b59f68ecc5 Add '@uiable' UI component library details and registry (#10976)
* Add '@uiable' UI component library details

Added new entry for '@uiable' with homepage, URL, description, and logo.

* Added @Uiable registry
2026-06-23 10:40:19 +04:00
Oliver Lewandowski
6956a099b3 feat(registry): add @7ovr to registry directory (#10989) 2026-06-23 10:39:46 +04:00
Liamandrew
2417242b12 feat(registry): add @tetra-ui (#10991) 2026-06-23 10:39:20 +04:00
Aniket Pawar
70a7a0c2a8 Update URL format and logo in directory.json (#10988) 2026-06-21 17:46:10 +04:00
Aniket Pawar
5602b81d83 Add new agent '@agentcn' to directory.json (#10985) 2026-06-21 15:49:35 +04:00
Raashish Aggarwal
3ffd3e1c7c fix(registry): update calendar month grid (#10644) 2026-06-19 11:38:39 +04:00
dependabot[bot]
e03df56fcf chore(deps): bump vitest from 2.1.9 to 3.2.6 (#10898)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 2.1.9 to 3.2.6.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v3.2.6/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 3.2.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: shadcn <m@shadcn.com>
2026-06-18 22:22:32 +04:00
dependabot[bot]
38fb1b6f41 chore(deps): bump astro from 6.3.8 to 6.4.6 in /templates/astro-monorepo (#10960)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 6.3.8 to 6.4.6.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@6.4.6/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 6.4.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 22:18:47 +04:00
shadcn
13eb6a81d1 fix: active style for carousel 2026-06-18 22:16:29 +04:00
Neon
5cc8a2af42 feat(registry): add @uui to registry directory (#10946) 2026-06-18 21:53:38 +04:00
shadcn
365d53b590 fix(shadcn): preserve existing dependency specifiers in package.json (#10967)
Fixes #10525
2026-06-18 21:46:26 +04:00
Saurabh
c2ddedf5d2 chore(registry): update beUI domain (#10961) 2026-06-18 11:16:18 +04:00
wrappixelTeam
c879483c96 Added Shadcn Space UI Kit to figma docs (#10839)
docs: Added Shadcn Space UI Kit to Figma paid section
2026-06-17 10:53:39 +04:00
jack-at-shadcn-ui-blocks
4885a9a7ad feat(registry): add shadcn-ui-blocks (#10950)
A growing library of shadcn/ui blocks, component variants, and templates —
free to start, with a Pro tier.

Co-authored-by: jack-at-shadcn-ui-blocks <293869725+jack-at-shadcn-ui-blocks@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:53:22 +04:00
dependabot[bot]
2f5929269a chore(deps-dev): bump vite in /templates/start-monorepo (#10959)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.14 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 10:52:59 +04:00
dependabot[bot]
2fdf1bb0d4 chore(deps-dev): bump vite in /templates/vite-monorepo (#10958)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.14 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 10:52:49 +04:00
dependabot[bot]
951750bdbe chore(deps-dev): bump vite in /templates/react-router-monorepo (#10957)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.14 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 10:52:38 +04:00
dependabot[bot]
6c5d5d6374 chore(deps-dev): bump vite in /templates/react-router-app (#10956)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.14 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 10:52:29 +04:00
shadcn
6ec117715f feat: add data-slot to spinner 2026-06-16 13:15:44 +04:00
shadcn
e583c773fe fix: dev server perf (#10954)
* fix: dev server perf

* fix
2026-06-16 11:20:07 +04:00
ChingRu
0f154171a7 feat(registry): add @heatmap to the directory (#10803) 2026-06-16 07:37:15 +04:00
shadcn
9197676b3d chore: replace node-fetch (#10905)
* chore: replace node-fetch

* fix(shadcn): surface network failure reason from fetch errors

* fix(shadcn): avoid Error.cause typings for older TS lib targets

* fix(shadcn): avoid stricter undici engine

* fix(shadcn): use undici 7 proxy agent
2026-06-15 14:08:41 +04:00
Ali Hussein
82dce7e945 fix(registry): remove duplicate entries from directory.json (#10912)
219 entries contained 10 duplicate registry names (209 unique).

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: shadcn <m@shadcn.com>
2026-06-15 10:22:53 +04:00
Saurabh
35bc9934bf fix(registry): add beUI logo (#10928) 2026-06-15 10:14:26 +04:00
khanhanh
1fd75c9d7e feat(kaui): add kaui component library (#10929)
* feat(kaui):  add kaui component library

* chore: remove unrelated formatting changes from registry directory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: shadcn <m@shadcn.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 10:06:21 +04:00
agonist
2ea31d8070 add contentbit registry (#10920) 2026-06-15 09:57:06 +04:00
rajtripathi99
ea9d371a2d Add @untld registry to the directory (#10903)
Registry URL: https://ui.untldlabs.com/r/{name}.json
Homepage: https://ui.untldlabs.com
2026-06-11 20:46:21 +04:00
JOE MON
d15f17d717 feat(registry): add braaile loader registry (#10923) 2026-06-11 20:44:29 +04:00
Saurabh
46ca8c5d4b feat(registry): add beui (#10887) 2026-06-11 20:41:01 +04:00
q32757468
a5eb279650 feat(registry): add gpt-vis registry (#10794) 2026-06-11 19:03:21 +04:00
shadcn
1994caba0b perf: dev server (#10904)
* perf: dev server

* fix
2026-06-10 11:10:01 +04:00
Koishore Roy
1450bea8d6 Add @delego to the registry directory (#10901) 2026-06-10 10:12:19 +04:00
Aniket Pawar
ced2a5beb5 Add new entry for @ogimagecn in directory.json (#10896) 2026-06-09 17:03:32 +04:00
Terra
10f1717a3e Add Saaskit component to directory.json (#10494)
* Add Saaskit component to directory.json

Added Saaskit component with description, URL, author, and logo.

* Update Saaskit entry in directory.json

* Add new registry entry for @saaskit

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:00:24 +04:00
Benedict Umeozor
deba036d50 fix(docs): disable ligatures in code blocks (#10809) 2026-06-08 23:51:25 +04:00
shadcn
5bd81beebf docs: add API reference for shadcn 2026-06-08 20:17:12 +04:00
github-actions[bot]
3f2ff18157 chore(release): version packages (#10873)
Co-authored-by: shadcn <m@shadcn.com>
2026-06-08 17:48:39 +04:00
shadcn
05eb2b968b feat(cli): improve search command (#10886)
- Search across multiple registries and make the registry argument
  optional: omit it to search every registry configured in components.json
  (builtins like @shadcn excluded). Without a components.json or configured
  registries, a clear usage error is printed.
- Add a --type filter (accepts "ui" or "registry:ui", comma-separated)
  with validation against the known item types.
- Fetch registries concurrently with a capped worker pool, preserving
  result order.
- Tolerate per-registry failures when searching all configured registries
  (reported in a structured `errors` field); exit non-zero when every
  registry fails. Usage errors print directly instead of routing through
  handleError.
- MCP parity: optional registries (search-all), a `types` filter, and type
  validation across the search/list/examples tools.
- Keep the public registry surface to `searchRegistries` and make it
  self-contained (clears its own context, useCache defaults to false).
- Consolidate search formatting into registry/search, add the `errors`
  field to searchResultsSchema, and update the skill docs.
2026-06-08 17:46:00 +04:00
Truong Giang
a721cc08e5 feat: add @soralabs registry (#10884)
* feat(registry): add @sora-ui to community registry directory

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(registry): rename @sora-ui to @soralabs in community registry directory

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 12:08:49 +04:00
shadcn
8da4592308 feat: update registry build commands (#10880)
* feat: update registry build commands

* fix
2026-06-06 23:19:46 +04:00
Sadman Sakib
f47d48f316 feat(registry): update diceui registry url to support style (#10881) 2026-06-06 23:19:34 +04:00
Franco Zeta
e6d9d6023b feat(registry): update @stepper logo (#10875) 2026-06-06 22:52:49 +04:00
Harshitha Sompura
7dfd933102 fix(cli): move msw to devDependencies (#10851)
* fix: move msw to devDependencies

* chore: update lockfile after moving msw to devDependencies

* chore: add changeset for msw devDependency fix

---------

Co-authored-by: shadcn <m@shadcn.com>
2026-06-05 21:01:37 +04:00
Andrew Luo
9c6a5ee1b1 add extend to registry directory (#10850) 2026-06-05 19:49:46 +04:00
Anish K Srinivasan
c87897b2a5 feat(registry): add @gamekitui to community registry directory (#10864)
Drop-in, themeable browser games for shadcn (Snake, 2048, Minesweeper, and
more) — each a single self-contained file with zero dependencies.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:46:49 +04:00
joncoronel
c61197f627 Add new UI component libraries to directory.json (#10858) 2026-06-05 19:45:13 +04:00
shadcn
a1fb619cef feat(card): add spacing and edge-to-edge variants (#10872) 2026-06-05 19:32:28 +04:00
Subhadip Jana
d84c4a8ca5 feat(registry): add @grootstudio to community registry directory (#10698) 2026-06-04 10:24:24 +04:00
Ajay Patel
cd54e0927f registry: updated shadcnstudio registry url with style support (#10847) 2026-06-01 20:22:30 +04:00
github-actions[bot]
adac7cae1f chore(release): version packages (#10845)
Co-authored-by: shadcn <m@shadcn.com>
2026-06-01 14:58:03 +04:00
shadcn
7c63c46736 feat(registry): add GitHub registry support (#10842)
* feat: add github scheme

* fix

* fix: validate and search

* docs: update docs for GitHub registries

* docs: add changelog

* fix

* chore: update announcement

* docs(skills): update GitHub registry guidance

* fix(registry): reject option-like GitHub refs

* fix(registry): limit search registry discovery

* fix(registry): bound GitHub validation concurrency

* fix(registry): reject whitespace in GitHub refs

* fix(registry): track URL dependency sources

* test(registry): cover local dependency sources
2026-06-01 14:53:34 +04:00
shadcn
916c012132 Sort registry directory entries alphabetically by name. (#10836) 2026-06-01 11:44:56 +04:00
github-actions[bot]
460ad60d84 chore(release): version packages (#10835)
Co-authored-by: shadcn <m@shadcn.com>
2026-05-31 16:18:29 +04:00
shadcn
8e2d2d1439 feat: add shadcn eject (#10834) 2026-05-31 16:11:01 +04:00
shadcn
67cef8fcb9 fix(v4): update homepage mobile demo fallback images (#10810)
* fix(v4): update homepage mobile demo fallback images

Replace registry dashboard screenshots with compressed CardsDemo captures and narrow the mobile bleed width to 140vw.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(v4): remove container-wrapper padding on homepage

Use p-0 on the demo section wrapper so the mobile preview image aligns flush without extra horizontal inset.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(v4): drop redundant md:p-0 on homepage wrapper

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:21:35 +04:00
shadcn
4ff43ba694 fix(styles): use color-mix for secondary button hover (#10808)
Align secondary button hover with Rhea: mix 5% foreground into
secondary in OKLCH instead of opacity. Updates registry CSS,
shipped button sources, and public registry JSON. Badges unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:37:28 +04:00
shadcn
efdec3ca45 fix(styles): restore primary button hover for Nova and Lyra (#10807)
The default button variant used [a]:hover, which only applies to anchor
elements. Buttons render as <button>, so hover had no effect on create
and in installed projects. Use hover:bg-primary/80 to match other styles.

Fixes #10798

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:13:58 +04:00
shadcn
5c849297d0 feat(release): add beta and rc prerelease labels (#10806) 2026-05-29 15:13:21 +04:00
github-actions[bot]
2baa86081d chore(release): version packages (#10791)
Co-authored-by: shadcn <m@shadcn.com>
2026-05-29 12:10:45 +04:00
shadcn
980f288149 ci(templates): test pnpm 11 (#10790) 2026-05-29 11:15:40 +04:00
Raashish Aggarwal
07900769d9 fix(cli): update template handling for pnpm 11 (#10659)
* fix(cli): allow esbuild builds in Vite templates

* fix(cli): extend pnpm 11 build-script allowlists across app templates

- Add packages: [] to single-app pnpm-workspace.yaml so pnpm 9 does
  not reject the file with "packages field missing or empty".
- Add astro-app, react-router-app, start-app, next-app workspace
  yamls with the build-script allowlist each template needs
  (esbuild, sharp, unrs-resolver as applicable).
- Set msw: false across all app allowlists so the registry component
  install runs cleanly under pnpm 11 without executing msw's
  service-worker postinstall.
- Add a scaffold test pinning the packages:[] + allowBuilds shape
  so the parser keeps treating it as single-app.

* chore: changeset

* fix(templates): allow monorepo pnpm builds

* ci(templates): validate app workspace conversion

---------

Co-authored-by: shadcn <m@shadcn.com>
2026-05-29 08:24:31 +04:00
Artyom Konoplyov
360e8a19c3 fix(transform-rtl): preserve quotes in transformed className literals (#10495)
* fix(transform-rtl): preserve string literal escapes

* chore(changeset): add rtl quote preservation note

---------

Co-authored-by: shadcn <m@shadcn.com>
2026-05-27 22:29:44 +04:00
884 changed files with 96059 additions and 26339 deletions

35
.github/collect-prerelease-info.js vendored Normal file
View File

@@ -0,0 +1,35 @@
// Collect the packages that a snapshot prerelease just published, so the
// prerelease comment workflow can render an install line per package.
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
const [, , prNumber, channel] = process.argv
const packagesDir = join(process.cwd(), "packages")
const published = []
for (const dir of readdirSync(packagesDir)) {
const pkgPath = join(packagesDir, dir, "package.json")
if (!existsSync(pkgPath)) {
continue
}
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
// Snapshot versions are stamped `0.0.0-<channel>-<timestamp>`, so the channel
// marker is how we tell which packages this run actually versioned.
if (
!pkg.private &&
typeof pkg.version === "string" &&
pkg.version.includes(`-${channel}`)
) {
published.push({ name: pkg.name, version: pkg.version })
}
}
writeFileSync(
"prerelease-info.json",
JSON.stringify({ pr: prNumber, channel, packages: published }, null, 2)
)
console.log(`Collected ${published.length} prerelease package(s).`)

View File

@@ -1,21 +0,0 @@
// ORIGINALLY FROM CLOUDFLARE WRANGLER:
// https://github.com/cloudflare/wrangler2/blob/main/.github/version-script.js
import { exec } from "child_process"
import fs from "fs"
const pkgJsonPath = "packages/shadcn/package.json"
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath))
exec("git rev-parse --short HEAD", (err, stdout) => {
if (err) {
console.log(err)
process.exit(1)
}
pkg.version = "0.0.0-beta." + stdout.trim()
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, "\t") + "\n")
})
} catch (error) {
console.error(error)
process.exit(1)
}

56
.github/workflows/browser-tests.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Browser tests
on:
pull_request:
branches: ["*"]
paths:
- "packages/react/**"
- ".github/workflows/browser-tests.yml"
jobs:
browser:
runs-on: ubuntu-latest
name: pnpm test:browser
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- uses: pnpm/action-setup@v4
name: Install pnpm
id: pnpm-install
with:
version: 10.33.4
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Cache Playwright browsers
uses: actions/cache@v3
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install Playwright Chromium
run: pnpm --filter=@shadcn/react exec playwright install --with-deps chromium
- run: pnpm --filter=@shadcn/react test:browser

View File

@@ -78,7 +78,7 @@ jobs:
run: pnpm install run: pnpm install
- name: Build packages - name: Build packages
run: pnpm --filter=shadcn build run: pnpm build:packages
- run: pnpm format:check - run: pnpm format:check
@@ -117,6 +117,6 @@ jobs:
run: pnpm install run: pnpm install
- name: Build packages - name: Build packages
run: pnpm --filter=shadcn build run: pnpm build:packages
- run: pnpm typecheck - run: pnpm typecheck

View File

@@ -1,5 +1,5 @@
# Adapted from create-t3-app. # Adapted from create-t3-app.
name: Write Beta Release comment name: Write Prerelease comment
on: on:
workflow_run: workflow_run:
@@ -16,51 +16,79 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Write comment to the PR name: Write comment to the PR
steps: steps:
- name: "Comment on PR" # Stable pushes and no-changeset runs upload no artifact, so a missing
# download is expected — gate the rest of the job on it succeeding.
- name: Download prerelease info
id: download
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: prerelease-info
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build comment
id: info
if: steps.download.outcome == 'success'
uses: actions/github-script@v7 uses: actions/github-script@v7
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ const fs = require("fs");
owner: context.repo.owner, const info = JSON.parse(fs.readFileSync("prerelease-info.json", "utf8"));
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
for (const artifact of allArtifacts.data.artifacts) { if (!info.packages || info.packages.length === 0) {
// Extract the PR number and package version from the artifact name core.info("No prerelease packages to comment.");
const match = /^npm-package-shadcn@(.*?)-pr-(\d+)/.exec(artifact.name); return;
if (match) {
require("fs").appendFileSync(
process.env.GITHUB_ENV,
`\nBETA_PACKAGE_VERSION=${match[1]}` +
`\nWORKFLOW_RUN_PR=${match[2]}` +
`\nWORKFLOW_RUN_ID=${context.payload.workflow_run.id}`
);
break;
}
} }
- name: "Comment on PR with Link" const installs = info.packages
.map((p) => `pnpm dlx ${p.name}@${p.version}`)
.join("\n");
const links = info.packages
.map(
(p) =>
`- [${p.name}@${p.version}](https://www.npmjs.com/package/${p.name}/v/${p.version})`
)
.join("\n");
const body = [
`A new ${info.channel} prerelease is available for testing:`,
"",
"```sh",
installs,
"```",
"",
links,
].join("\n");
core.setOutput("pr", info.pr);
core.setOutput("channel", info.channel);
core.setOutput("body", body);
- name: Comment on PR
if: steps.info.outputs.body
uses: marocchino/sticky-pull-request-comment@v2 uses: marocchino/sticky-pull-request-comment@v2
with: with:
number: ${{ env.WORKFLOW_RUN_PR }} number: ${{ steps.info.outputs.pr }}
message: | message: ${{ steps.info.outputs.body }}
A new prerelease is available for testing:
```sh - name: Remove the prerelease label once published
pnpm dlx shadcn@${{ env.BETA_PACKAGE_VERSION }} if: steps.info.outputs.pr
```
- name: "Remove the autorelease label once published"
uses: actions/github-script@v7 uses: actions/github-script@v7
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |
github.rest.issues.removeLabel({ try {
await github.rest.issues.removeLabel({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issue_number: '${{ env.WORKFLOW_RUN_PR }}', issue_number: Number("${{ steps.info.outputs.pr }}"),
name: '🚀 autorelease', name: `release: ${{ steps.info.outputs.channel }}`,
}); });
} catch (error) {
if (error.status !== 404) {
throw error;
}
core.info("The prerelease label was already removed.");
}

View File

@@ -2,7 +2,7 @@
name: Release name: Release
run-name: ${{ github.event_name == 'pull_request' && format('Release Beta - PR {0}', github.event.number) || 'Release Stable' }} run-name: ${{ github.event_name == 'pull_request' && format('Release Prerelease - PR {0}', github.event.number) || 'Release Stable' }}
on: on:
pull_request: pull_request:
@@ -15,8 +15,8 @@ on:
jobs: jobs:
prerelease: prerelease:
if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && contains(github.event.pull_request.labels.*.name, '🚀 autorelease') }} if: "${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && (contains(github.event.pull_request.labels.*.name, 'release: beta') || contains(github.event.pull_request.labels.*.name, 'release: rc')) }}"
name: Publish Beta to NPM name: Publish Prerelease to NPM
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: Preview environment: Preview
permissions: permissions:
@@ -24,10 +24,36 @@ jobs:
contents: read contents: read
steps: steps:
- name: Select prerelease channel
id: prerelease
uses: actions/github-script@v7
with:
script: |
const prereleaseLabels = [
{ name: "release: beta", channel: "beta" },
{ name: "release: rc", channel: "rc" },
];
const labels = context.payload.pull_request.labels.map((label) => label.name);
const selectedLabels = prereleaseLabels.filter((label) =>
labels.includes(label.name)
);
if (selectedLabels.length !== 1) {
throw new Error(
`Expected exactly one prerelease label, found: ${
selectedLabels.map((label) => label.name).join(", ") || "none"
}.`
);
}
core.setOutput("channel", selectedLabels[0].channel);
core.setOutput("label", selectedLabels[0].name);
- name: Checkout Repo - name: Checkout Repo
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Use PNPM - name: Use PNPM
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -47,23 +73,49 @@ jobs:
- name: Install NPM Dependencies - name: Install NPM Dependencies
run: pnpm install run: pnpm install
- name: Modify package.json version # A snapshot prerelease needs changesets to compute versions. The
run: node .github/version-script-beta.js # Changesets version PR consumes them, so a label on that PR is a no-op.
- name: Check for changesets
id: changesets
run: |
shopt -s nullglob
present=false
for file in .changeset/*.md; do
if [ "$(basename "$file")" != "README.md" ]; then
present=true
break
fi
done
echo "present=$present" >> "$GITHUB_OUTPUT"
- name: Publish Beta to NPM - name: No changesets to prerelease
run: pnpm pub:beta if: steps.changesets.outputs.present == 'false'
run: echo "::notice::No changesets found on this branch; nothing to prerelease."
- name: get-npm-version # Snapshot versions are stamped per run (timestamped), so each publish is
id: package-version # unique and can never collide with a real release on the latest tag.
uses: martinbeentjes/npm-get-version-action@main - name: Version snapshot
with: if: steps.changesets.outputs.present == 'true'
path: packages/shadcn run: pnpm exec changeset version --snapshot ${{ steps.prerelease.outputs.channel }}
- name: Upload packaged artifact - name: Build packages
if: steps.changesets.outputs.present == 'true'
run: pnpm build:packages
- name: Publish snapshot to NPM
if: steps.changesets.outputs.present == 'true'
run: pnpm exec changeset publish --tag ${{ steps.prerelease.outputs.channel }} --no-git-tag
- name: Collect prerelease info
if: steps.changesets.outputs.present == 'true'
run: node .github/collect-prerelease-info.js "${{ github.event.number }}" "${{ steps.prerelease.outputs.channel }}"
- name: Upload prerelease info
if: steps.changesets.outputs.present == 'true'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name name: prerelease-info
path: packages/shadcn/dist/index.js path: prerelease-info.json
release: release:
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }} if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
@@ -98,11 +150,10 @@ jobs:
- name: Install NPM Dependencies - name: Install NPM Dependencies
run: pnpm install run: pnpm install
# - name: Check for errors # Builds every publishable package under packages/* (shadcn, @shadcn/react),
# run: pnpm check # never apps/v4, so each dist is fresh before changeset publish.
- name: Build the packages
- name: Build the package run: pnpm build:packages
run: pnpm shadcn:build
- name: Import GPG key - name: Import GPG key
uses: crazy-max/ghaction-import-gpg@v6 uses: crazy-max/ghaction-import-gpg@v6

View File

@@ -19,7 +19,7 @@ on:
jobs: jobs:
validate: validate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: ${{ matrix.package-manager }} ${{ matrix.template }} name: ${{ matrix.package-manager == 'pnpm' && format('pnpm {0}', matrix.pnpm-version) || matrix.package-manager }} ${{ matrix.template }}
permissions: permissions:
contents: read contents: read
timeout-minutes: 45 timeout-minutes: 45
@@ -28,11 +28,20 @@ jobs:
matrix: matrix:
template: [next, vite, astro, start, react-router] template: [next, vite, astro, start, react-router]
package-manager: [pnpm, bun, npm, yarn] package-manager: [pnpm, bun, npm, yarn]
pnpm-version: [10.33.4, 11]
exclude:
- package-manager: bun
pnpm-version: 11
- package-manager: npm
pnpm-version: 11
- package-manager: yarn
pnpm-version: 11
env: env:
NEXT_PUBLIC_APP_URL: http://localhost:4000 NEXT_PUBLIC_APP_URL: http://localhost:4000
NEXT_PUBLIC_V0_URL: https://v0.dev NEXT_PUBLIC_V0_URL: https://v0.dev
REGISTRY_URL: http://localhost:4000/r REGISTRY_URL: http://localhost:4000/r
TEMPLATE_PNPM_VERSION: 10.33.4 ROOT_PNPM_VERSION: 10.33.4
TEMPLATE_PNPM_VERSION: ${{ matrix.pnpm-version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -48,7 +57,7 @@ jobs:
name: Install pnpm name: Install pnpm
id: pnpm-install id: pnpm-install
with: with:
version: 10.33.4 version: ${{ env.ROOT_PNPM_VERSION }}
run_install: false run_install: false
- name: Install Bun - name: Install Bun
@@ -131,6 +140,7 @@ jobs:
local package_manager="$1" local package_manager="$1"
local project_path="$2" local project_path="$2"
local check_workspace_protocol="$3" local check_workspace_protocol="$3"
local is_monorepo="$4"
cd "$project_path" cd "$project_path"
test ! -f pnpm-workspace.yaml test ! -f pnpm-workspace.yaml
@@ -138,6 +148,7 @@ jobs:
EXPECTED_PACKAGE_MANAGER="$package_manager" \ EXPECTED_PACKAGE_MANAGER="$package_manager" \
CHECK_WORKSPACE_PROTOCOL="$check_workspace_protocol" \ CHECK_WORKSPACE_PROTOCOL="$check_workspace_protocol" \
IS_MONOREPO="$is_monorepo" \
node <<'NODE' node <<'NODE'
const fs = require("node:fs") const fs = require("node:fs")
const path = require("node:path") const path = require("node:path")
@@ -145,7 +156,10 @@ jobs:
const expectedPackageManager = process.env.EXPECTED_PACKAGE_MANAGER const expectedPackageManager = process.env.EXPECTED_PACKAGE_MANAGER
const checkWorkspaceProtocol = const checkWorkspaceProtocol =
process.env.CHECK_WORKSPACE_PROTOCOL === "true" process.env.CHECK_WORKSPACE_PROTOCOL === "true"
const isMonorepo = process.env.IS_MONOREPO === "true"
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")) const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
if (isMonorepo) {
const workspaces = pkg.workspaces ?? [] const workspaces = pkg.workspaces ?? []
if (!Array.isArray(workspaces)) { if (!Array.isArray(workspaces)) {
@@ -167,6 +181,17 @@ jobs:
`Expected packageManager to use ${expectedPackageManager}, got ${pkg.packageManager}` `Expected packageManager to use ${expectedPackageManager}, got ${pkg.packageManager}`
) )
} }
} else {
if (pkg.workspaces !== undefined) {
throw new Error("Did not expect package.json workspaces for app template.")
}
if (pkg.packageManager !== undefined) {
throw new Error(
`Did not expect packageManager for app template, got ${pkg.packageManager}`
)
}
}
if (checkWorkspaceProtocol) { if (checkWorkspaceProtocol) {
const packageJsonFiles = [] const packageJsonFiles = []
@@ -213,8 +238,10 @@ jobs:
if [ "$mode" = "monorepo" ]; then if [ "$mode" = "monorepo" ]; then
args+=(--monorepo) args+=(--monorepo)
is_monorepo="true"
else else
args+=(--no-monorepo) args+=(--no-monorepo)
is_monorepo="false"
fi fi
case "$TEMPLATE_PACKAGE_MANAGER" in case "$TEMPLATE_PACKAGE_MANAGER" in
@@ -238,7 +265,11 @@ jobs:
bunx --bun --package "$GITHUB_WORKSPACE/packages/shadcn" \ bunx --bun --package "$GITHUB_WORKSPACE/packages/shadcn" \
shadcn "${args[@]}" shadcn "${args[@]}"
) )
validate_non_pnpm_project "bun" "$project_path" "false" validate_non_pnpm_project \
"bun" \
"$project_path" \
"false" \
"$is_monorepo"
;; ;;
npm) npm)
( (
@@ -249,7 +280,11 @@ jobs:
npx --yes --package "$GITHUB_WORKSPACE/packages/shadcn" \ npx --yes --package "$GITHUB_WORKSPACE/packages/shadcn" \
shadcn "${args[@]}" shadcn "${args[@]}"
) )
validate_non_pnpm_project "npm" "$project_path" "true" validate_non_pnpm_project \
"npm" \
"$project_path" \
"true" \
"$is_monorepo"
;; ;;
yarn) yarn)
( (
@@ -261,7 +296,11 @@ jobs:
yarn dlx --package "$GITHUB_WORKSPACE/packages/shadcn" \ yarn dlx --package "$GITHUB_WORKSPACE/packages/shadcn" \
shadcn "${args[@]}" shadcn "${args[@]}"
) )
validate_non_pnpm_project "yarn" "$project_path" "false" validate_non_pnpm_project \
"yarn" \
"$project_path" \
"false" \
"$is_monorepo"
;; ;;
esac esac

View File

@@ -46,3 +46,39 @@ jobs:
run: pnpm install run: pnpm install
- run: pnpm test - run: pnpm test
react:
runs-on: ubuntu-latest
name: pnpm test (@shadcn/react)
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
id: pnpm-install
with:
version: 10.33.4
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- run: pnpm --filter=@shadcn/react test

7
.gitignore vendored
View File

@@ -45,4 +45,11 @@ tsconfig.tsbuildinfo
.playwright-mcp .playwright-mcp
.playwright-cli .playwright-cli
shadcn-workspace shadcn-workspace
# vitest browser mode writes these only on test failure.
__screenshots__
.codex-artifacts .codex-artifacts
.tmp*
CONTEXT.md
docs/adr

View File

@@ -15,6 +15,7 @@
"search.exclude": { "search.exclude": {
"apps/v4/registry/radix-*": true, "apps/v4/registry/radix-*": true,
"apps/v4/public/r/*": true, "apps/v4/public/r/*": true,
"packages/shadcn/test/fixtures/*": true "packages/shadcn/test/fixtures/*": true,
"apps/v4/styles/*": true
} }
} }

View File

@@ -141,6 +141,11 @@ When adding or modifying components, please ensure that:
2. You update the documentation. 2. You update the documentation.
3. You run `pnpm registry:build` to update the registry. 3. You run `pnpm registry:build` to update the registry.
See [`apps/v4/registry/README.md`](apps/v4/registry/README.md) for how the
registry pipeline is structured and for the faster targeted build modes
(`--style`, `--registry`, `--examples`, `--indexes`) you can use while
iterating locally. Always run the full `pnpm registry:build` before committing.
## Commit Convention ## Commit Convention
Before you create a Pull Request, please check whether your commits comply with Before you create a Pull Request, please check whether your commits comply with

59
RELEASING.md Normal file
View File

@@ -0,0 +1,59 @@
# Releasing
This monorepo publishes two packages independently with [Changesets](https://github.com/changesets/changesets):
- **`shadcn`** — the CLI and tooling.
- **`@shadcn/react`** — headless React primitives.
They version on their own lines. A change to one never bumps the other unless a changeset says so.
## 1. Add a changeset
Every change that should publish needs a changeset. Run:
```sh
pnpm changeset
```
Select the affected package(s) and bump level. One PR can carry separate changesets for `shadcn` and `@shadcn/react` at different levels. A PR with no changeset publishes nothing.
## 2. Stable release
Stable releases are automated by `.github/workflows/release.yml` (the `release` job, on push to `main`):
1. Merged changesets accumulate on `main`.
2. The Changesets action opens/updates a **"Version Packages"** PR that bumps versions and writes changelogs.
3. Merging that PR triggers `changeset publish`, which builds all packages (`pnpm build:packages`) and publishes any whose version is ahead of npm — each to the `latest` tag.
`pnpm build:packages` (`turbo run build --filter=./packages/*`) builds `shadcn` and `@shadcn/react` but never `apps/v4`.
## 3. Prereleases (per-PR snapshots)
Add the **`release: beta`** or **`release: rc`** label to a PR. The `prerelease` job in `release.yml`:
1. Verifies the branch has changesets (a label on the version PR is a no-op, since it consumed them).
2. Runs `changeset version --snapshot <channel>` — stamps a unique `0.0.0-<channel>-<timestamp>` on each changeset'd package.
3. Builds and runs `changeset publish --tag <channel> --no-git-tag`.
4. Uploads the published package list; `prerelease-comment.yml` posts a `pnpm dlx` install line per package and removes the label.
The label selects the **dist-tag/channel**; the **changesets on the branch** select which packages publish. Snapshots are timestamped, so they never touch `latest` and never collide.
```sh
# Install a snapshot from the PR comment, e.g.:
pnpm dlx @shadcn/react@0.0.0-beta-20260624120000
```
## 4. Prerelease trains (sustained `-beta.N` / `-rc.N`)
For a baking release line (e.g. `1.0.0-rc.0`, `-rc.1`, …) rather than throwaway snapshots, use Changesets pre mode:
```sh
pnpm changeset pre enter rc # writes .changeset/pre.json
# ...normal changeset + Version PR cycle now produces -rc.N versions on the rc tag...
pnpm changeset pre exit # back to stable; next Version PR ships X.Y.Z on latest
```
## Notes
- `pnpm-workspace.yaml` sets `minimumReleaseAge: 2880` (48h), so freshly published stable/beta versions take time to resolve in normal installs. Use `pnpm dlx <pkg>@<exact-snapshot-version>` to test immediately.
- Publishing uses npm OIDC/provenance (`id-token: write` + `npm@latest`); no `NPM_TOKEN` secret is needed.

1
apps/v4/.gitignore vendored
View File

@@ -46,3 +46,4 @@ next-env.d.ts
.contentlayer .contentlayer
.content-collections .content-collections
.source .source
.devtools

View File

@@ -17,7 +17,6 @@ const chartData = [
{ month: "Feb", amount: 900 }, { month: "Feb", amount: 900 },
{ month: "Mar", amount: 1300 }, { month: "Mar", amount: 1300 },
{ month: "Apr", amount: 750 }, { month: "Apr", amount: 750 },
{ month: "May", amount: 1400 },
] ]
export function ContributionHistory() { export function ContributionHistory() {
@@ -35,13 +34,14 @@ export function ContributionHistory() {
role="img" role="img"
aria-label="Last 6 months of contribution activity" aria-label="Last 6 months of contribution activity"
> >
{chartData.map((item) => ( {chartData.map((item, index) => (
<div <div
key={item.month} key={item.month}
className="flex h-full flex-1 flex-col justify-end gap-2" className="flex h-full flex-1 flex-col justify-end gap-2"
> >
<div <div
className="min-h-2 rounded-t-md bg-chart-2" data-index={index}
className="data-[index=5]:bg-chart-6 min-h-2 rounded-lg data-[index=0]:bg-chart-1 data-[index=1]:bg-chart-2 data-[index=2]:bg-chart-3 data-[index=3]:bg-chart-4 data-[index=4]:bg-chart-5"
style={{ height: `${(item.amount / maxAmount) * 100}%` }} style={{ height: `${(item.amount / maxAmount) * 100}%` }}
/> />
<span className="text-center text-xs text-muted-foreground"> <span className="text-center text-xs text-muted-foreground">

View File

@@ -1,3 +1,5 @@
import { MessageScrollerDemo } from "@/examples/radix/message-scroller-demo"
import { AccountAccess } from "./account-access" import { AccountAccess } from "./account-access"
import { AnalyticsCard } from "./analytics-card" import { AnalyticsCard } from "./analytics-card"
import { ClaimableBalance } from "./claimable-balance" import { ClaimableBalance } from "./claimable-balance"
@@ -79,7 +81,7 @@ export function CardsDemo() {
return ( return (
<div <div
data-slot="demo" data-slot="demo"
className="theme-neutral relative flex w-full max-w-none flex-col gap-(--gap) overflow-hidden bg-muted p-12 pb-0! [--gap:--spacing(8)] 3xl:[--gap:--spacing(8)] min-[1900px]:p-12 min-[1900px]:[--gap:--spacing(10)]! lg:p-6 lg:[--gap:--spacing(6)] dark:bg-background" className="theme-blue relative flex w-full max-w-none flex-col gap-(--gap) overflow-hidden bg-muted p-12 pb-0! [--gap:--spacing(8)] 3xl:[--gap:--spacing(8)] min-[1900px]:p-12 min-[1900px]:[--gap:--spacing(10)]! lg:p-6 lg:[--gap:--spacing(6)] dark:bg-background"
> >
<CardsSkeletonRails /> <CardsSkeletonRails />
<div className="relative z-10 mx-auto grid gap-(--gap) **:data-[slot=card]:w-full min-[1400px]:grid-cols-4! min-[1900px]:grid-cols-5! md:max-w-3xl md:grid-cols-2 lg:max-w-none lg:grid-cols-3 xl:max-w-[1600px] 2xl:max-w-[1900px]"> <div className="relative z-10 mx-auto grid gap-(--gap) **:data-[slot=card]:w-full min-[1400px]:grid-cols-4! min-[1900px]:grid-cols-5! md:max-w-3xl md:grid-cols-2 lg:max-w-none lg:grid-cols-3 xl:max-w-[1600px] 2xl:max-w-[1900px]">
@@ -93,17 +95,20 @@ export function CardsDemo() {
<ClaimableBalance /> <ClaimableBalance />
<DividendIncome /> <DividendIncome />
</div> </div>
<div className="hidden flex-col gap-(--gap) 3xl:flex!"> <div className="hidden flex-col gap-(--gap) min-[1400px]:flex">
<NewMilestone /> <NewMilestone />
<PayoutThreshold /> <PayoutThreshold />
<AccountAccess /> <AccountAccess />
</div> </div>
<div className="hidden flex-col gap-(--gap) md:flex"> <div className="hidden flex-col gap-(--gap) md:flex">
<QrConnect /> <QrConnect />
<TransferFunds /> <div className="**:[.text-center.text-xs]:hidden">
<MessageScrollerDemo />
</div>
{/* <TransferFunds /> */}
<Payments /> <Payments />
</div> </div>
<div className="hidden flex-col gap-(--gap) min-[1400px]:flex"> <div className="hidden flex-col gap-(--gap) min-[1900px]:flex">
<EmptyDistributeTrack /> <EmptyDistributeTrack />
<AnalyticsCard /> <AnalyticsCard />
<NotificationSettings /> <NotificationSettings />

View File

@@ -117,7 +117,7 @@ export function UIElements() {
</span> </span>
<span className="flex md:hidden style-sera:md:flex">Dialog</span> <span className="flex md:hidden style-sera:md:flex">Dialog</span>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent size="sm"> <AlertDialogContent size="sm" className="theme-blue">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Allow accessory to connect?</AlertDialogTitle> <AlertDialogTitle>Allow accessory to connect?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>

View File

@@ -60,23 +60,23 @@ export default function IndexPage() {
</Button> </Button>
</PageActions> </PageActions>
</PageHeader> </PageHeader>
<div className="container-wrapper flex-1 pb-6 md:px-0"> <div className="container-wrapper flex-1 p-0">
<div className="container overflow-hidden md:px-0 lg:max-w-none"> <div className="container overflow-hidden md:px-0 lg:max-w-none">
<section className="-mx-4 w-[160vw] overflow-hidden rounded-lg border border-border/50 md:hidden md:w-[150vw]"> <section className="-mx-4 w-[140vw] overflow-hidden md:hidden">
<Image <Image
src="/r/styles/new-york-v4/dashboard-01-light.png" src="/images/full-light.png"
width={1400} width={2560}
height={875} height={2764}
alt="Dashboard" alt="Dashboard"
className="block dark:hidden" className="block h-auto w-full dark:hidden"
priority priority
/> />
<Image <Image
src="/r/styles/new-york-v4/dashboard-01-dark.png" src="/images/full-dark.png"
width={1400} width={2560}
height={875} height={2764}
alt="Dashboard" alt="Dashboard"
className="hidden dark:block" className="hidden h-auto w-full dark:block"
priority priority
/> />
</section> </section>

View File

@@ -0,0 +1,72 @@
"use client"
import * as React from "react"
import { BASES } from "@/registry/config"
import { Button } from "@/registry/new-york-v4/ui/button"
import { getDocsPathForItem } from "@/app/(app)/create/lib/devtools"
import {
serializeDesignSystemSearchParams,
useDesignSystemSearchParams,
} from "@/app/(app)/create/lib/search-params"
export function CreateDevtools() {
const [params, setParams] = useDesignSystemSearchParams()
const previewUrl = React.useMemo(
() =>
serializeDesignSystemSearchParams(
`/preview/${params.base}/${params.item}`,
params
),
[params]
)
const docsUrl = React.useMemo(
() => getDocsPathForItem(params.base, params.item),
[params.base, params.item]
)
if (process.env.NODE_ENV === "production") {
return null
}
return (
<div className="dark absolute bottom-3 left-1/2 z-20 flex -translate-x-1/2 items-center gap-0.5 rounded-xl bg-card/90 p-1 shadow-xl backdrop-blur-xl">
{BASES.map((base) => (
<Button
key={base.name}
variant="ghost"
size="sm"
data-active={params.base === base.name}
className="h-7 min-w-8 cursor-pointer rounded-lg px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground"
onClick={() => setParams({ base: base.name })}
>
{base.name === "radix" ? "Radix" : "Base"}
</Button>
))}
<div className="mx-0.5 h-4 w-px bg-border/80" />
<Button
asChild
variant="ghost"
size="sm"
className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<a href={previewUrl} target="_blank" rel="noreferrer">
Open in New Tab
</a>
</Button>
<div className="mx-0.5 h-4 w-px bg-border/80" />
<Button
asChild
variant="ghost"
size="sm"
className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<a href={docsUrl} target="_blank" rel="noreferrer">
Open Docs
</a>
</Button>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import { type RegistryItem } from "shadcn/schema"
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile"
import { getThemesForBaseColor, STYLES } from "@/registry/config" import { getThemesForBaseColor, STYLES } from "@/registry/config"
import { Button } from "@/styles/base-nova/ui/button"
import { import {
Card, Card,
CardContent, CardContent,
@@ -32,11 +33,22 @@ import { ThemePicker } from "@/app/(app)/create/components/theme-picker"
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts" import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params" import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
// Only visible when user clicks "Create Project". // Only visible when user clicks "Create Project". Rendered client-only to
const ProjectForm = dynamic(() => // avoid a useId hydration mismatch on the Base UI dialog trigger. The loading
// placeholder mirrors the trigger button exactly so there is no layout shift.
const ProjectForm = dynamic(
() =>
import("@/app/(app)/create/components/project-form").then( import("@/app/(app)/create/components/project-form").then(
(m) => m.ProjectForm (m) => m.ProjectForm
) ),
{
ssr: false,
loading: () => (
<Button disabled aria-hidden>
Get Code
</Button>
),
}
) )
export function Customizer({ export function Customizer({

View File

@@ -3,6 +3,7 @@
import * as React from "react" import * as React from "react"
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/create/components/action-menu" import { CMD_K_FORWARD_TYPE } from "@/app/(app)/create/components/action-menu"
import { CreateDevtools } from "@/app/(app)/create/components/create-devtools"
import { import {
REDO_FORWARD_TYPE, REDO_FORWARD_TYPE,
UNDO_FORWARD_TYPE, UNDO_FORWARD_TYPE,
@@ -160,6 +161,7 @@ export function Preview() {
title="Preview" title="Preview"
/> />
</div> </div>
<CreateDevtools />
<PreviewSwitcher /> <PreviewSwitcher />
</div> </div>
) )

View File

@@ -42,7 +42,8 @@ export async function getBaseComponent(name: string, base: BaseName) {
return null return null
} }
return index[name].component const { Components } = await import("@/registry/bases/__components__")
return Components[base]?.[name] ?? null
} }
export async function getAllItems() { export async function getAllItems() {

View File

@@ -0,0 +1,10 @@
import { type BaseName } from "@/registry/config"
export function getDocsPathForItem(base: BaseName, item: string) {
if (item.endsWith("-example")) {
const component = item.slice(0, -"-example".length)
return `/docs/components/${base}/${component}`
}
return "/docs/components"
}

View File

@@ -185,7 +185,7 @@ export default async function Page(props: {
<div className="sticky top-[calc(var(--header-height)+1px)] z-30 ml-auto hidden h-[90svh] w-(--sidebar-width) flex-col gap-4 overflow-hidden overscroll-none pb-8 xl:flex"> <div className="sticky top-[calc(var(--header-height)+1px)] z-30 ml-auto hidden h-[90svh] w-(--sidebar-width) flex-col gap-4 overflow-hidden overscroll-none pb-8 xl:flex">
<div className="h-(--top-spacing) shrink-0"></div> <div className="h-(--top-spacing) shrink-0"></div>
{doc.toc?.length ? ( {doc.toc?.length ? (
<div className="no-scrollbar flex flex-col gap-8 overflow-y-auto px-8"> <div className="flex scroll-fade scrollbar-none flex-col gap-8 overflow-y-auto px-8">
<DocsTableOfContents toc={doc.toc} /> <DocsTableOfContents toc={doc.toc} />
</div> </div>
) : null} ) : null}

View File

@@ -16,6 +16,7 @@ import { RandomizeScript } from "@/app/(app)/create/components/random-button"
import { getBaseComponent, getBaseItem } from "@/app/(app)/create/lib/api" import { getBaseComponent, getBaseItem } from "@/app/(app)/create/lib/api"
import "@/app/style-registry.css" import "@/app/style-registry.css"
import "streamdown/styles.css"
export const revalidate = false export const revalidate = false
export const dynamic = "force-static" export const dynamic = "force-static"

View File

@@ -0,0 +1,74 @@
import { type Metadata } from "next"
import { notFound } from "next/navigation"
import { ExamplesComponents } from "@/examples/__components__"
import { ExamplesIndex } from "@/examples/__index__"
import { siteConfig } from "@/lib/config"
import { absoluteUrl } from "@/lib/utils"
export const dynamicParams = true
export const revalidate = 3600
function getExample(base: string, name: string) {
const item = ExamplesIndex[base]?.[name]
const Component = ExamplesComponents[base]?.[name]
if (!item || !Component) {
return null
}
return { item, Component }
}
export async function generateMetadata({
params,
}: {
params: Promise<{ base: string; name: string }>
}): Promise<Metadata> {
const { base, name } = await params
const example = getExample(base, name)
if (!example) {
return {}
}
const title = example.item.name
return {
title,
openGraph: {
title,
type: "article",
url: absoluteUrl(`/examples/${base}/${title}`),
images: [
{
url: siteConfig.ogImage,
width: 1200,
height: 630,
alt: siteConfig.name,
},
],
},
twitter: {
card: "summary_large_image",
title,
images: [siteConfig.ogImage],
creator: "@shadcn",
},
}
}
export default async function ExamplePage({
params,
}: {
params: Promise<{ base: string; name: string }>
}) {
const { base, name } = await params
const example = getExample(base, name)
if (!example) {
return notFound()
}
const { Component } = example
return <Component />
}

View File

@@ -3,6 +3,8 @@
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@import "./legacy-themes.css"; @import "./legacy-themes.css";
@source "../node_modules/streamdown/dist/*.js";
@custom-variant style-vega (&:where(.style-vega *)); @custom-variant style-vega (&:where(.style-vega *));
@custom-variant style-nova (&:where(.style-nova *)); @custom-variant style-nova (&:where(.style-nova *));
@custom-variant style-lyra (&:where(.style-lyra *)); @custom-variant style-lyra (&:where(.style-lyra *));
@@ -284,6 +286,14 @@
} }
} }
[data-rehype-pretty-code-figure] code,
[data-rehype-pretty-code-figure] code span {
font-variant-ligatures: none;
font-feature-settings:
"liga" 0,
"calt" 0;
}
[data-rehype-pretty-code-title] { [data-rehype-pretty-code-title] {
border-bottom: color-mix(in oklab, var(--border) 30%, transparent); border-bottom: color-mix(in oklab, var(--border) 30%, transparent);
border-bottom-width: 1px; border-bottom-width: 1px;

View File

@@ -563,3 +563,74 @@
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
} }
} }
.theme-blue {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--selection: oklch(0.93 0.03 256);
--selection-foreground: oklch(0.145 0 0);
@variant dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.424 0.199 265.638);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
}

View File

@@ -7,7 +7,7 @@ export function Announcement() {
return ( return (
<Badge asChild variant="secondary" className="bg-muted"> <Badge asChild variant="secondary" className="bg-muted">
<Link href="/docs/changelog"> <Link href="/docs/changelog">
Introducing Rhea <ArrowRightIcon /> Components for Chat Interfaces <ArrowRightIcon />
</Link> </Link>
</Badge> </Badge>
) )

View File

@@ -6,6 +6,7 @@ import { IconArrowRight } from "@tabler/icons-react"
import { useDocsSearch } from "fumadocs-core/search/client" import { useDocsSearch } from "fumadocs-core/search/client"
import { CornerDownLeftIcon, SquareDashedIcon } from "lucide-react" import { CornerDownLeftIcon, SquareDashedIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui" import { Dialog as DialogPrimitive } from "radix-ui"
import { encodePreset } from "shadcn/preset"
import { type Color, type ColorPalette } from "@/lib/colors" import { type Color, type ColorPalette } from "@/lib/colors"
import { trackEvent } from "@/lib/events" import { trackEvent } from "@/lib/events"
@@ -36,6 +37,7 @@ import {
} from "@/registry/new-york-v4/ui/dialog" } from "@/registry/new-york-v4/ui/dialog"
import { Separator } from "@/registry/new-york-v4/ui/separator" import { Separator } from "@/registry/new-york-v4/ui/separator"
import { Spinner } from "@/registry/new-york-v4/ui/spinner" import { Spinner } from "@/registry/new-york-v4/ui/spinner"
import { STYLES } from "@/registry/styles"
export function CommandMenu({ export function CommandMenu({
tree, tree,
@@ -56,7 +58,7 @@ export function CommandMenu({
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false)
const [renderDelayedGroups, setRenderDelayedGroups] = React.useState(false) const [renderDelayedGroups, setRenderDelayedGroups] = React.useState(false)
const [selectedType, setSelectedType] = React.useState< const [selectedType, setSelectedType] = React.useState<
"color" | "page" | "component" | "block" | null "color" | "page" | "component" | "block" | "style" | null
>(null) >(null)
const [copyPayload, setCopyPayload] = React.useState("") const [copyPayload, setCopyPayload] = React.useState("")
@@ -208,6 +210,40 @@ export function CommandMenu({
) )
}, [navItems, runCommand, router]) }, [navItems, runCommand, router])
const stylesSection = React.useMemo(() => {
return (
<CommandGroup
heading="Styles"
className="p-0! **:[[cmdk-group-heading]]:scroll-mt-16 **:[[cmdk-group-heading]]:p-3! **:[[cmdk-group-heading]]:pb-1!"
>
{STYLES.map((style) => (
<CommandMenuItem
key={style.name}
value={`Style ${style.title} ${style.description}`}
keywords={["style", "preset", style.name, style.title]}
onHighlight={() => {
setSelectedType("style")
setCopyPayload("")
}}
onSelect={() => {
runCommand(() =>
router.push(
`/create?preset=${encodePreset({ style: style.name })}`
)
)
}}
>
{style.icon}
{style.title}
<span className="ml-auto text-xs font-normal text-muted-foreground">
Open style in shadcn/create
</span>
</CommandMenuItem>
))}
</CommandGroup>
)
}, [runCommand, router])
const pageGroupsSection = React.useMemo(() => { const pageGroupsSection = React.useMemo(() => {
return tree.children.map((group) => { return tree.children.map((group) => {
if (group.type !== "folder") { if (group.type !== "folder") {
@@ -425,6 +461,7 @@ export function CommandMenu({
{query.isLoading ? "Searching..." : "No results found."} {query.isLoading ? "Searching..." : "No results found."}
</CommandEmpty> </CommandEmpty>
{navItemsSection} {navItemsSection}
{stylesSection}
{renderDelayedGroups ? ( {renderDelayedGroups ? (
<> <>
{pageGroupsSection} {pageGroupsSection}
@@ -448,6 +485,7 @@ export function CommandMenu({
? "Go to Page" ? "Go to Page"
: null} : null}
{selectedType === "color" ? "Copy OKLCH" : null} {selectedType === "color" ? "Copy OKLCH" : null}
{selectedType === "style" ? "Open in shadcn/create" : null}
</div> </div>
{copyPayload && ( {copyPayload && (
<> <>

View File

@@ -1,33 +1,65 @@
import Link from "next/link" import Link from "next/link"
import { PAGES_NEW } from "@/lib/docs" import { PAGES_NEW } from "@/lib/docs"
import { getPagesFromFolder, type PageTreeFolder } from "@/lib/page-tree" import {
getPagesFromFolder,
type PageTreeFolder,
type PageTreePage,
} from "@/lib/page-tree"
export function ComponentsList({ function ComponentLink({
componentsFolder, component,
currentBase, showNewIndicator,
}: { }: {
componentsFolder: PageTreeFolder component: PageTreePage
currentBase: string showNewIndicator: boolean
}) { }) {
const list = getPagesFromFolder(componentsFolder, currentBase) const isNew = showNewIndicator && PAGES_NEW.includes(component.url)
return ( return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-x-8 lg:gap-x-16 lg:gap-y-6 xl:gap-x-20">
{list.map((component) => (
<Link <Link
key={component.$id}
href={component.url} href={component.url}
className="inline-flex items-center gap-2 text-lg font-medium underline-offset-4 hover:underline md:text-base" className="inline-flex items-center gap-2 text-lg font-medium underline-offset-4 hover:underline md:text-base"
> >
{component.name} {component.name}
{PAGES_NEW.includes(component.url) && ( {isNew && (
<>
<span className="sr-only">New</span>
<span <span
aria-hidden="true"
className="flex size-2 rounded-full bg-blue-500" className="flex size-2 rounded-full bg-blue-500"
title="New"
/> />
</>
)} )}
</Link> </Link>
)
}
export function ComponentsList({
componentsFolder,
currentBase,
variant = "all",
}: {
componentsFolder: PageTreeFolder
currentBase: string
variant?: "all" | "new"
}) {
const list = getPagesFromFolder(componentsFolder, currentBase).filter(
(component) => variant === "all" || PAGES_NEW.includes(component.url)
)
if (!list.length) {
return null
}
return (
<div className="mt-8 grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-x-8 lg:gap-x-16 lg:gap-y-6 xl:gap-x-20">
{list.map((component) => (
<ComponentLink
key={component.$id}
component={component}
showNewIndicator={variant === "all"}
/>
))} ))}
</div> </div>
) )

View File

@@ -6,10 +6,12 @@ import { BASES } from "@/registry/bases"
export function DocsBaseSwitcher({ export function DocsBaseSwitcher({
base, base,
component, component,
hrefPrefix = "/docs/components",
className, className,
}: { }: {
base: string base: string
component: string component: string
hrefPrefix?: string
className?: string className?: string
}) { }) {
const activeBase = BASES.find((baseItem) => base === baseItem.name) const activeBase = BASES.find((baseItem) => base === baseItem.name)
@@ -19,7 +21,7 @@ export function DocsBaseSwitcher({
{BASES.map((baseItem) => ( {BASES.map((baseItem) => (
<Link <Link
key={baseItem.name} key={baseItem.name}
href={`/docs/components/${baseItem.name}/${component}`} href={`${hrefPrefix}/${baseItem.name}/${component}`}
data-active={base === baseItem.name} data-active={base === baseItem.name}
className="relative inline-flex items-center justify-center gap-1 pt-1 pb-0.5 text-base font-medium text-muted-foreground transition-colors after:absolute after:inset-x-0 after:bottom-[-4px] after:h-0.5 after:bg-foreground after:opacity-0 after:transition-opacity hover:text-foreground data-[active=true]:text-foreground data-[active=true]:after:opacity-100" className="relative inline-flex items-center justify-center gap-1 pt-1 pb-0.5 text-base font-medium text-muted-foreground transition-colors after:absolute after:inset-x-0 after:bottom-[-4px] after:h-0.5 after:bg-foreground after:opacity-0 after:transition-opacity hover:text-foreground data-[active=true]:text-foreground data-[active=true]:after:opacity-100"
> >

View File

@@ -73,14 +73,12 @@ export function DocsSidebar({
return ( return (
<Sidebar <Sidebar
className="sticky top-[calc(var(--header-height)+0.6rem)] z-30 hidden h-[calc(100svh-10rem)] overscroll-none bg-transparent [--sidebar-menu-width:--spacing(56)] lg:flex" className="sticky top-[calc(var(--header-height)+0.6rem)] z-30 hidden h-[calc(100svh-10rem)] overflow-hidden overscroll-none bg-transparent [--sidebar-menu-width:--spacing(56)] lg:flex"
collapsible="none" collapsible="none"
{...props} {...props}
> >
<div className="h-9" /> <SidebarContent className="w-(--sidebar-menu-width) scroll-fade scrollbar-none overflow-x-hidden pl-2.5">
<div className="absolute top-8 z-10 h-8 w-(--sidebar-menu-width) shrink-0 bg-linear-to-b from-background via-background/80 to-background/50 blur-xs" /> <SidebarGroup className="pt-12">
<SidebarContent className="no-scrollbar w-(--sidebar-menu-width) overflow-x-hidden px-2.5">
<SidebarGroup className="pt-6">
<SidebarGroupLabel className="font-medium text-muted-foreground"> <SidebarGroupLabel className="font-medium text-muted-foreground">
Sections Sections
</SidebarGroupLabel> </SidebarGroupLabel>
@@ -167,7 +165,6 @@ export function DocsSidebar({
</SidebarGroup> </SidebarGroup>
) )
})} })}
<div className="sticky -bottom-1 z-10 h-16 shrink-0 bg-linear-to-t from-background via-background/80 to-background/50 blur-xs" />
</SidebarContent> </SidebarContent>
</Sidebar> </Sidebar>
) )

View File

@@ -107,7 +107,7 @@ export function DocsTableOfContents({
return ( return (
<div className={cn("flex flex-col gap-2 p-4 pt-0 text-sm", className)}> <div className={cn("flex flex-col gap-2 p-4 pt-0 text-sm", className)}>
<p className="sticky top-0 h-6 bg-background text-xs font-medium text-muted-foreground"> <p className="h-6 bg-background text-xs font-medium text-muted-foreground">
On This Page On This Page
</p> </p>
{toc.map((item) => ( {toc.map((item) => (

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { code } from "@streamdown/code"
import { Streamdown } from "streamdown"
import { cn } from "@/lib/utils"
const DEFAULT_PLUGINS = { code }
function Markdown({
className,
plugins = DEFAULT_PLUGINS,
controls = false,
...props
}: React.ComponentProps<typeof Streamdown>) {
return (
<Streamdown
data-slot="markdown"
plugins={plugins}
controls={controls}
className={cn("cn-markdown w-full min-w-0 overflow-hidden", className)}
{...props}
/>
)
}
export { Markdown }

View File

@@ -0,0 +1,148 @@
"use client"
import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import type { MessageAnimationPreset } from "@/lib/message-animations"
import { MESSAGE_ANIMATIONS } from "@/lib/message-animations"
import { Bubble, BubbleContent } from "@/styles/radix-rhea/ui/bubble"
import { Message, MessageContent } from "@/styles/radix-rhea/ui/message"
import { MessageScrollerItem } from "@/styles/radix-rhea/ui/message-scroller"
type MessageAnimatedPart = {
type: string
text?: string
}
type MessageAnimatedMessage = {
id: string
role: string
text?: string
parts?: ReadonlyArray<MessageAnimatedPart>
}
type MessageAnimatedTextPart = {
key: string
text: string
}
const MotionMessageScrollerItem = motion.create(MessageScrollerItem)
function MessageAnimated({
message,
animationPreset = MESSAGE_ANIMATIONS["slide-up"],
assistantVariant = "ghost",
scrollAnchor,
userVariant = "muted",
...props
}: Omit<
React.ComponentProps<typeof MotionMessageScrollerItem>,
"animate" | "children" | "exit" | "initial" | "messageId" | "variants"
> & {
animationPreset?: MessageAnimationPreset
assistantVariant?: React.ComponentProps<typeof Bubble>["variant"]
message: MessageAnimatedMessage
userVariant?: React.ComponentProps<typeof Bubble>["variant"]
}) {
const shouldReduceMotion = useReducedMotion()
const isUserMessage = message.role === "user"
if (isUserMessage) {
return (
<MotionMessageScrollerItem
messageId={message.id}
scrollAnchor={scrollAnchor ?? true}
variants={animationPreset.variants}
initial={shouldReduceMotion ? false : "initial"}
animate="animate"
exit={shouldReduceMotion ? undefined : "exit"}
{...props}
>
<MessageAnimatedRow
message={message}
assistantVariant={assistantVariant}
userVariant={userVariant}
/>
</MotionMessageScrollerItem>
)
}
return (
<MotionMessageScrollerItem
messageId={message.id}
scrollAnchor={scrollAnchor}
initial={false}
{...props}
>
<MessageAnimatedRow
message={message}
assistantVariant={assistantVariant}
userVariant={userVariant}
/>
</MotionMessageScrollerItem>
)
}
function MessageAnimatedRow({
message,
assistantVariant,
userVariant,
}: {
assistantVariant: React.ComponentProps<typeof Bubble>["variant"]
message: MessageAnimatedMessage
userVariant: React.ComponentProps<typeof Bubble>["variant"]
}) {
const isUserMessage = message.role === "user"
const textParts = getMessageAnimatedTextParts(message)
return (
<Message align={isUserMessage ? "end" : "start"}>
<MessageContent>
{textParts.map((part) => {
const paragraphs = part.text
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
return (
<Bubble
key={part.key}
variant={isUserMessage ? userVariant : assistantVariant}
>
<BubbleContent className="space-y-2">
{paragraphs.map((paragraph, paragraphIndex) => (
<p
key={`${part.key}-${paragraphIndex}`}
className="whitespace-pre-wrap"
>
{paragraph}
</p>
))}
</BubbleContent>
</Bubble>
)
})}
</MessageContent>
</Message>
)
}
function getMessageAnimatedTextParts(
message: MessageAnimatedMessage
): MessageAnimatedTextPart[] {
if (message.parts) {
return message.parts.flatMap((part, index) => {
if (part.type !== "text" || typeof part.text !== "string") {
return []
}
return [{ key: `${message.id}-${index}`, text: part.text }]
})
}
return typeof message.text === "string"
? [{ key: `${message.id}-text`, text: message.text }]
: []
}
export { MessageAnimated, type MessageAnimatedMessage }

View File

@@ -0,0 +1,645 @@
import * as React from "react"
type MessagePartKind =
| "text"
| "reasoning"
| "tool"
| "file"
| "source"
| "data"
| "custom"
| "step-start"
| "reasoning-file"
| (string & {})
type MessagePartOf<TMessage> = TMessage extends {
parts: ReadonlyArray<infer TPart>
}
? TPart
: unknown
type MessagePartItem<TPart = unknown, TMessage = unknown> = {
key: string
part: TPart
message: TMessage
index: number
kind: MessagePartKind
name?: string
toolCallId?: string
}
type MessageToolPartGroup<TPart = unknown, TMessage = unknown> = {
key: string
name?: string
toolCallId?: string
items: Array<MessagePartItem<TPart, TMessage>>
call?: MessagePartItem<TPart, TMessage>
result?: MessagePartItem<TPart, TMessage>
}
type MessagePartsResult<TPart = unknown, TMessage = unknown> = {
role?: string
all: Array<MessagePartItem<TPart, TMessage>>
protocol: Array<MessagePartItem<TPart, TMessage>>
byKind: Record<string, Array<MessagePartItem<TPart, TMessage>>>
tools: Array<MessageToolPartGroup<TPart, TMessage>>
toolParts: Array<MessagePartItem<TPart, TMessage>>
text: Array<MessagePartItem<TPart, TMessage>>
texts: Array<MessagePartItem<TPart, TMessage>>
reasoning: Array<MessagePartItem<TPart, TMessage>>
files: Array<MessagePartItem<TPart, TMessage>>
sources: Array<MessagePartItem<TPart, TMessage>>
data: Array<MessagePartItem<TPart, TMessage>>
custom: Array<MessagePartItem<TPart, TMessage>>
steps: Array<MessagePartItem<TPart, TMessage>>
reasoningFiles: Array<MessagePartItem<TPart, TMessage>>
get: (kind: MessagePartKind) => Array<MessagePartItem<TPart, TMessage>>
}
type MessagePartsAdapter<TMessage, TPart> = {
getParts?: (message: TMessage) => ReadonlyArray<TPart>
getRole?: (message: TMessage) => string | undefined
getPartKey?: (part: TPart, index: number, message: TMessage) => string
getPartKind?: (
part: TPart,
index: number,
message: TMessage
) => MessagePartKind
getPartName?: (
part: TPart,
index: number,
message: TMessage
) => string | undefined
getToolCallId?: (
part: TPart,
index: number,
message: TMessage
) => string | undefined
isToolCall?: (part: TPart, index: number, message: TMessage) => boolean
isToolResult?: (part: TPart, index: number, message: TMessage) => boolean
}
type CreateMessagePartsOptions<TMessage, TPart> = {
adapter?: MessagePartsAdapter<TMessage, TPart>
order?:
| "role"
| "protocol"
| MessagePartKind[]
| ((
items: Array<MessagePartItem<TPart, TMessage>>,
message: TMessage
) => Array<MessagePartItem<TPart, TMessage>>)
classifyPart?: (
part: TPart,
context: {
message: TMessage
index: number
kind: MessagePartKind
name?: string
toolCallId?: string
}
) =>
| {
kind?: MessagePartKind
name?: string
toolCallId?: string
}
| undefined
getKey?: (part: TPart, index: number, message: TMessage) => string
}
type MessagePartRenderProps<TPart = unknown, TMessage = unknown> = {
item: MessagePartItem<TPart, TMessage>
part: TPart
message: TMessage
index: number
kind: MessagePartKind
name?: string
toolCallId?: string
}
type MessagePartComponent<
TPart = unknown,
TMessage = unknown,
> = React.ComponentType<MessagePartRenderProps<TPart, TMessage>>
type MessagePartComponents<TPart = unknown, TMessage = unknown> = {
text?: MessagePartComponent<TPart, TMessage>
reasoning?: MessagePartComponent<TPart, TMessage>
tool?: MessagePartComponent<TPart, TMessage>
file?: MessagePartComponent<TPart, TMessage>
source?: MessagePartComponent<TPart, TMessage>
data?: MessagePartComponent<TPart, TMessage>
custom?: MessagePartComponent<TPart, TMessage>
stepStart?: MessagePartComponent<TPart, TMessage>
reasoningFile?: MessagePartComponent<TPart, TMessage>
fallback?: MessagePartComponent<TPart, TMessage>
types?: Record<string, MessagePartComponent<TPart, TMessage>>
tools?: Record<string, MessagePartComponent<TPart, TMessage>>
dataTypes?: Record<string, MessagePartComponent<TPart, TMessage>>
}
type MessagePartProps<TPart = unknown, TMessage = unknown> = {
item: MessagePartItem<TPart, TMessage>
components: MessagePartComponents<TPart, TMessage>
}
type MessagePartsProps<TPart = unknown, TMessage = unknown> = {
parts:
| MessagePartsResult<TPart, TMessage>
| Array<MessagePartItem<TPart, TMessage>>
components: MessagePartComponents<TPart, TMessage>
renderPart?: (props: {
item: MessagePartItem<TPart, TMessage>
children: React.ReactNode
}) => React.ReactNode
}
const ASSISTANT_PART_ORDER: MessagePartKind[] = [
"reasoning",
"tool",
"reasoning-file",
"file",
"text",
"source",
"data",
"custom",
"step-start",
]
const USER_PART_ORDER: MessagePartKind[] = [
"file",
"text",
"data",
"source",
"custom",
]
const DEFAULT_PART_ORDER: MessagePartKind[] = ["text", "data", "custom"]
function createMessageParts<TMessage>(
message: TMessage,
options: CreateMessagePartsOptions<TMessage, MessagePartOf<TMessage>> = {}
): MessagePartsResult<MessagePartOf<TMessage>, TMessage> {
type TPart = MessagePartOf<TMessage>
const adapter = options.adapter ?? {}
const role = adapter.getRole?.(message) ?? getStructuralRole(message)
const messageParts =
adapter.getParts?.(message) ??
(getStructuralParts(message) as ReadonlyArray<TPart>)
const protocol = messageParts.map((part, index) => {
const kind =
adapter.getPartKind?.(part, index, message) ?? getStructuralPartKind(part)
const name =
adapter.getPartName?.(part, index, message) ?? getStructuralPartName(part)
const toolCallId =
adapter.getToolCallId?.(part, index, message) ??
getStructuralToolCallId(part)
const classification = options.classifyPart?.(part, {
message,
index,
kind,
name,
toolCallId,
})
return {
key:
options.getKey?.(part, index, message) ??
adapter.getPartKey?.(part, index, message) ??
getStructuralPartKey(part, index),
part,
message,
index,
kind: classification?.kind ?? kind,
name: classification?.name ?? name,
toolCallId: classification?.toolCallId ?? toolCallId,
} satisfies MessagePartItem<TPart, TMessage>
})
const all = orderMessagePartItems(
protocol,
options.order ?? "role",
role,
message
)
const byKind = groupMessagePartItems(all)
function get(kind: MessagePartKind) {
return byKind[kind] ?? []
}
return {
role,
all,
protocol,
byKind,
tools: groupToolPartItems(all, adapter, message),
toolParts: get("tool"),
text: get("text"),
texts: get("text"),
reasoning: get("reasoning"),
files: get("file"),
sources: get("source"),
data: get("data"),
custom: get("custom"),
steps: get("step-start"),
reasoningFiles: get("reasoning-file"),
get,
}
}
function MessageParts<TPart, TMessage>({
parts,
components,
renderPart,
}: MessagePartsProps<TPart, TMessage>) {
const items = Array.isArray(parts) ? parts : parts.all
return (
<>
{items.map((item) => {
const children = (
<MessagePart key={item.key} item={item} components={components} />
)
return renderPart ? (
<React.Fragment key={item.key}>
{renderPart({ item, children })}
</React.Fragment>
) : (
children
)
})}
</>
)
}
function MessagePart<TPart, TMessage>({
item,
components,
}: MessagePartProps<TPart, TMessage>) {
const Component = getMessagePartComponent(item, components)
if (!Component) {
return null
}
return React.createElement(Component, {
item,
part: item.part,
message: item.message,
index: item.index,
kind: item.kind,
name: item.name,
toolCallId: item.toolCallId,
})
}
function getMessagePartComponent<TPart, TMessage>(
item: MessagePartItem<TPart, TMessage>,
components: MessagePartComponents<TPart, TMessage>
) {
const partType = getStructuralPartType(item.part)
if (partType && components.types?.[partType]) {
return components.types[partType]
}
if (item.kind === "tool" && item.name && components.tools?.[item.name]) {
return components.tools[item.name]
}
if (item.kind === "data" && item.name && components.dataTypes?.[item.name]) {
return components.dataTypes[item.name]
}
if (item.kind === "text") {
return components.text ?? components.fallback
}
if (item.kind === "reasoning") {
return components.reasoning ?? components.fallback
}
if (item.kind === "tool") {
return components.tool ?? components.fallback
}
if (item.kind === "file") {
return components.file ?? components.fallback
}
if (item.kind === "source") {
return components.source ?? components.fallback
}
if (item.kind === "data") {
return components.data ?? components.fallback
}
if (item.kind === "custom") {
return components.custom ?? components.fallback
}
if (item.kind === "step-start") {
return components.stepStart ?? components.fallback
}
if (item.kind === "reasoning-file") {
return components.reasoningFile ?? components.file ?? components.fallback
}
return components.fallback
}
function orderMessagePartItems<TPart, TMessage>(
items: Array<MessagePartItem<TPart, TMessage>>,
order: NonNullable<CreateMessagePartsOptions<TMessage, TPart>["order"]>,
role: string | undefined,
message: TMessage
) {
if (order === "protocol") {
return items
}
if (typeof order === "function") {
return order(items, message)
}
const orderedKinds = Array.isArray(order)
? order
: role === "user"
? USER_PART_ORDER
: role === "assistant"
? ASSISTANT_PART_ORDER
: DEFAULT_PART_ORDER
const orderWeight = new Map(orderedKinds.map((kind, index) => [kind, index]))
return [...items].sort((a, b) => {
const aWeight = orderWeight.get(a.kind) ?? Number.MAX_SAFE_INTEGER
const bWeight = orderWeight.get(b.kind) ?? Number.MAX_SAFE_INTEGER
return aWeight === bWeight ? a.index - b.index : aWeight - bWeight
})
}
function groupMessagePartItems<TPart, TMessage>(
items: Array<MessagePartItem<TPart, TMessage>>
) {
return items.reduce<Record<string, Array<MessagePartItem<TPart, TMessage>>>>(
(groups, item) => {
groups[item.kind] ??= []
groups[item.kind].push(item)
return groups
},
{}
)
}
function groupToolPartItems<TPart, TMessage>(
items: Array<MessagePartItem<TPart, TMessage>>,
adapter: MessagePartsAdapter<TMessage, TPart>,
message: TMessage
) {
const groups: MessageToolPartGroup<TPart, TMessage>[] = []
const groupsByKey = new Map<string, MessageToolPartGroup<TPart, TMessage>>()
for (const item of items) {
if (item.kind !== "tool") {
continue
}
const groupKey = item.toolCallId ?? item.name ?? item.key
let group = groupsByKey.get(groupKey)
if (!group) {
group = {
key: groupKey,
name: item.name,
toolCallId: item.toolCallId,
items: [],
}
groupsByKey.set(groupKey, group)
groups.push(group)
}
group.items.push(item)
if (adapter.isToolResult?.(item.part, item.index, message)) {
group.result = item
} else if (adapter.isToolCall?.(item.part, item.index, message)) {
group.call = item
} else if (isStructuralToolResult(item.part)) {
group.result = item
} else if (!group.call) {
group.call = item
}
}
return groups
}
function getStructuralParts(message: unknown) {
if (isRecord(message) && Array.isArray(message.parts)) {
return message.parts
}
return []
}
function getStructuralRole(message: unknown) {
if (isRecord(message) && typeof message.role === "string") {
return message.role
}
return undefined
}
function getStructuralPartKey(part: unknown, index: number) {
const type = getStructuralPartType(part) ?? "part"
const toolCallId = getStructuralToolCallId(part)
if (toolCallId) {
return `${type}:${toolCallId}`
}
if (isRecord(part)) {
if (typeof part.sourceId === "string") {
return `${type}:${part.sourceId}`
}
if (typeof part.id === "string") {
return `${type}:${part.id}`
}
if (typeof part.url === "string") {
return `${type}:${part.url}:${index}`
}
}
return `${type}:${index}`
}
function getStructuralPartKind(part: unknown): MessagePartKind {
const type = getStructuralPartType(part)
if (!type) {
return "custom"
}
if (type === "text") {
return "text"
}
if (type === "reasoning" || type === "thinking") {
return "reasoning"
}
if (
type === "file" ||
type === "image" ||
type === "audio" ||
type === "video"
) {
return "file"
}
if (type === "reasoning-file") {
return "reasoning-file"
}
if (
type === "source" ||
type === "source-url" ||
type === "source-document" ||
type === "citation" ||
type === "search-result"
) {
return "source"
}
if (
type === "dynamic-tool" ||
type === "tool-call" ||
type === "tool-result" ||
type === "tool_call" ||
type === "tool_result" ||
type.startsWith("tool-")
) {
return "tool"
}
if (type.startsWith("data-")) {
return "data"
}
if (type === "step-start" || type === "step_start") {
return "step-start"
}
if (type === "custom") {
return "custom"
}
return "custom"
}
function getStructuralPartName(part: unknown) {
const type = getStructuralPartType(part)
if (!type) {
return undefined
}
if (isRecord(part)) {
if (typeof part.toolName === "string") {
return part.toolName
}
if (typeof part.name === "string") {
return part.name
}
if (typeof part.kind === "string") {
return part.kind
}
}
if (type.startsWith("tool-")) {
return type.slice("tool-".length)
}
if (type.startsWith("data-")) {
return type.slice("data-".length)
}
if (type === "source-url") {
return "url"
}
if (type === "source-document") {
return "document"
}
return undefined
}
function getStructuralToolCallId(part: unknown) {
if (!isRecord(part)) {
return undefined
}
if (typeof part.toolCallId === "string") {
return part.toolCallId
}
if (typeof part.callId === "string") {
return part.callId
}
return undefined
}
function isStructuralToolResult(part: unknown) {
const type = getStructuralPartType(part)
return (
type === "tool-result" ||
type === "tool_result" ||
(isRecord(part) && "output" in part)
)
}
function getStructuralPartType(part: unknown) {
if (isRecord(part) && typeof part.type === "string") {
return part.type
}
return undefined
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
export {
MessagePart,
MessageParts,
createMessageParts,
getMessagePartComponent,
type CreateMessagePartsOptions,
type MessagePartComponent,
type MessagePartComponents,
type MessagePartItem,
type MessagePartKind,
type MessagePartProps,
type MessagePartRenderProps,
type MessagePartsAdapter,
type MessagePartsProps,
type MessagePartsResult,
type MessageToolPartGroup,
}

View File

@@ -3,6 +3,8 @@ title: shadcn
description: Use the shadcn CLI to add components to your project. description: Use the shadcn CLI to add components to your project.
--- ---
import { TriangleAlertIcon } from "lucide-react"
## init ## init
Use the `init` command to initialize configuration and dependencies for an existing project, or create a new project with `--name`. Use the `init` command to initialize configuration and dependencies for an existing project, or create a new project with `--name`.
@@ -503,3 +505,87 @@ npx shadcn@latest migrate radix "src/components/ui/**"
If no path is provided, the migration will transform all files in your `ui` directory (from `components.json`). If no path is provided, the migration will transform all files in your `ui` directory (from `components.json`).
Once complete, you can remove any unused `@radix-ui/react-*` packages from your `package.json`. Once complete, you can remove any unused `@radix-ui/react-*` packages from your `package.json`.
---
## eject
When you run `init`, shadcn adds `@import "shadcn/tailwind.css"` to your global CSS file. This import provides shared Tailwind v4 utilities such as custom variants (`data-open:`, `data-closed:`, etc.) and accordion animations.
Use the `eject` command to inline `shadcn/tailwind.css` into your global CSS file and remove the `shadcn` dependency from your project.
<Callout icon={<TriangleAlertIcon />}>
**Note: This action is irreversible.** After ejecting, future shadcn CLI
updates to `shadcn/tailwind.css` will not apply automatically.
</Callout>
```bash
npx shadcn@latest eject
```
**Before**
```css
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
```
**After**
```css
@import "tailwindcss";
@import "tw-animate-css";
/* ejected from shadcn@4.8.3 */
@theme inline {
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(
--radix-accordion-content-height,
var(--accordion-panel-height, auto)
);
}
}
}
@custom-variant data-open {
&:where([data-state="open"]),
&:where([data-open]:not([data-open="false"])) {
@slot;
}
}
@utility no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
```
**Monorepo**
In a monorepo, run the command from the workspace that contains your `components.json` and global CSS file:
```bash
npx shadcn@latest eject -c packages/ui
```
**Options**
```bash
Usage: shadcn eject [options]
inline shadcn/tailwind.css and remove the shadcn dependency
Options:
-c, --cwd <cwd> the working directory. defaults to the current directory.
-y, --yes skip confirmation prompt. (default: false)
-s, --silent mute output. (default: false)
-h, --help display help for command
```

View File

@@ -20,3 +20,4 @@ description: Every component recreated in Figma. With customizable props, typogr
- [shadcn/studio UI Kit](https://shadcnstudio.com/figma) - Accelerate design & development with a shadcn/ui compatible Figma kit with updated components, 550+ blocks, 10+ templates, 20+ themes, and an AI tool that converts designs into shadcn/ui code. - [shadcn/studio UI Kit](https://shadcnstudio.com/figma) - Accelerate design & development with a shadcn/ui compatible Figma kit with updated components, 550+ blocks, 10+ templates, 20+ themes, and an AI tool that converts designs into shadcn/ui code.
- [Shadcnblocks.com](https://www.shadcnblocks.com) - A Premium Shadcn Figma UI Kit with components, 500+ pro blocks, shadcn theme variables, light/dark mode and Figma MCP ready. - [Shadcnblocks.com](https://www.shadcnblocks.com) - A Premium Shadcn Figma UI Kit with components, 500+ pro blocks, shadcn theme variables, light/dark mode and Figma MCP ready.
- [Obra shadcn/ui Pro](https://shadcn.obra.studio/products/obra-shadcn-ui-pro) by [Obra Studio](https://obra.studio/) - Focused on designers who need to get work done — the best designer experience for shadcn/ui within Figma. variable consistency with shadcn, plus custom components, Pro blocks, and a design-to-code plugin. - [Obra shadcn/ui Pro](https://shadcn.obra.studio/products/obra-shadcn-ui-pro) by [Obra Studio](https://obra.studio/) - Focused on designers who need to get work done — the best designer experience for shadcn/ui within Figma. variable consistency with shadcn, plus custom components, Pro blocks, and a design-to-code plugin.
- [Shadcn Space](https://shadcnspace.com/figma) - A collection of beautifully designed 320+ blocks, 9+ templates, and 250+ components with a shadcn/ui-compatible Figma kit built for modern React and Next.js workflows. Design, prototype, and ship faster using Figma components that mirror real code architecture and production-ready implementation patterns.

View File

@@ -0,0 +1,61 @@
---
title: May 2026 - shadcn eject
description: Inline shadcn/tailwind.css and remove the shadcn dependency.
date: 2026-05-31
---
When we added support for both Radix and Base UI, we needed a place for shared Tailwind utilities that both libraries depend on, e.g. custom variants like `data-open:` and `data-closed:` and utilities like `no-scrollbar`.
We also ran into a few bugs while working on RTL support that were easier to fix in one shared place rather than duplicating across every component.
So we created `shadcn/tailwind.css`. When you run `init`, it adds `@import "shadcn/tailwind.css"` to your global CSS file. It works just like other CSS imports such as `tw-animate-css`: a small dependency that is tree-shaken in production and resolved at build time.
If you prefer not to depend on the `shadcn` package for that CSS, we've added the `shadcn eject` command. It inlines `shadcn/tailwind.css` into your global CSS file and removes the `shadcn` dependency from your project.
```bash
npx shadcn@latest eject
```
**Before**
```css
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
```
**After**
```css
@import "tailwindcss";
@import "tw-animate-css";
/* ejected from shadcn@4.8.3 */
@theme inline {
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(
--radix-accordion-content-height,
var(--accordion-panel-height, auto)
);
}
}
}
@custom-variant data-open {
&:where([data-state="open"]),
&:where([data-open]:not([data-open="false"])) {
@slot;
}
}
```
In a monorepo, run the command from the workspace that contains your `components.json` and global CSS file:
```bash
npx shadcn@latest eject -c packages/ui
```
See the [CLI documentation](/docs/cli#eject) for more details.

View File

@@ -0,0 +1,117 @@
---
title: June 2026 - Components for Chat Interfaces
description: MessageScroller, Message, Bubble, Attachment, and Marker. Components for building chat interfaces.
date: 2026-06-26
---
<ComponentPreview
styleName="radix-rhea"
name="message-scroller-demo"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
Today, were releasing a new set of components for building chat interfaces:
[**MessageScroller**](/docs/components/message-scroller),
[**Message**](/docs/components/message), [**Bubble**](/docs/components/bubble),
[**Attachment**](/docs/components/attachment), and
[**Marker**](/docs/components/marker).
This is the first phase of the chat components work. Were taking it one piece at a time, reimagining the abstraction behind each part, and shipping them as shadcn/ui components you can copy, compose, and adapt to your product.
We are starting with the conversation layer: scrolling, message rows, bubbles, attachments, and markers.
We asked ourselves: what makes a great streaming chat experience? Then we abstracted the core rules into a set of primitives: `MessageScroller`.
```bash
npx shadcn@latest add message-scroller message bubble attachment marker
```
## MessageScroller
`MessageScroller` is the scroll container for a conversation. It handles the
parts that are easy to get wrong: anchored turns, streamed replies, saved thread
restore, prepended history, jump-to-message, scroll controls, and visibility
tracking.
`MessageScroller` owns that behavior without owning your messages, AI state,
transport, persistence, or model state. You bring the content renderer.
The `MessageScroller` is also available as an unstyled headless component in `@shadcn/react`.
## Message, Bubble, Attachment, and Marker
The rest of the components cover the everyday pieces you need around the
scroller.
- `Message` lays out a row in the conversation with avatar, alignment, header,
content, footer, and grouped messages.
- `Bubble` renders the message surface, with variants, alignment, reactions,
links, buttons, and collapsible content.
- `Attachment` renders files and images with media, metadata, upload state,
actions, and a full-card trigger that keeps actions separately clickable.
- `Marker` renders status updates, system notes, bordered rows, and labeled
separators for things like streaming state, tool activity, and date breaks.
They are intentionally small. Compose them together for AI chats, support
inboxes, team threads, group chats, and product-specific conversations.
## scroll-fade and shimmer
We also added two new CSS utilities for the details that make chat interfaces
feel better.
[`scroll-fade`](/docs/utils/scroll-fade) adds scroll-aware edge fades to scroll
containers. Use it on `MessageScroller`, `ScrollArea`, attachment rows, and any
long list where you want to hint at more content without adding overlays or
scroll listeners.
<ComponentPreview
styleName="radix-rhea"
name="scroll-fade-demo"
previewClassName="h-auto"
/>
[`shimmer`](/docs/utils/shimmer) adds a text shimmer for live status. Use it
for things like "Thinking…", "Generating response…", running tools, and
streaming markers.
<ComponentPreview styleName="radix-rhea" name="shimmer-demo" />
Both utilities ship with `shadcn/tailwind.css`, so projects initialized with
`npx shadcn@latest init` already have them.
## @shadcn/react
We also created `@shadcn/react`, a new package for unstyled, headless React
components.
The first primitive is `@shadcn/react/message-scroller`. The registry component
wraps it with shadcn/ui styles, but the scroll behavior lives in the package:
anchoring, auto-follow, prepend preservation, scroll commands, and visibility.
This lets us ship behavior without locking it to a visual style. You still get
copy-and-paste components that match your project, and the hard interaction
logic stays tested in one place.
Available now for Radix and Base UI.
## AI Elements
This does not replace [AI Elements](https://ai-sdk.dev/elements/overview). You
can keep using AI Elements for AI interface components and patterns. This
release is about bringing the core pieces of chat into shadcn/ui, one component
at a time.
If you are already using a component from AI Elements, you do not need to
rewrite your app. Keep what works. Try the shadcn/ui version when you want the
newer abstraction, the updated styling, or support across Radix and Base UI.
The goal is to make these pieces easy to adopt independently. Replace one part,
compose it with what you already have, and keep building.
<Button asChild size="sm">
<Link href="/docs/components" className="mt-6 no-underline!">
View Components
</Link>
</Button>

View File

@@ -0,0 +1,98 @@
---
title: June 2026 - GitHub Registries
description: Turn any public GitHub repository into a shadcn registry.
date: 2026-06-01
---
**You can now turn any public GitHub repository into a registry.**
Add a `registry.json` file at the root of the repository, define the items you
want to distribute, and users can install them directly from GitHub with the
`shadcn` CLI.
```bash
npx shadcn@latest add <username>/<repo>/<item>
```
For example, to install the `project-conventions` item from the `acme/toolkit` repository:
```bash
npx shadcn@latest add acme/toolkit/project-conventions
```
GitHub registries are source registries. You do not need to run `shadcn build`,
publish generated item JSON files or set up a registry server. The CLI reads the
root `registry.json`, resolves `include` entries, finds the requested item and
installs the files declared by that item.
## Distribute anything
Registry items are not limited to components. A GitHub registry can distribute
components, hooks, utilities, design tokens, feature kits, project conventions,
agent instructions, testing setup, CI workflows, release workflows, templates,
codemods, migration kits and other project files.
For example, a repository can expose a `project-conventions` item that installs
shared docs, editor settings and agent instructions:
```json title="registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme-toolkit",
"homepage": "https://github.com/acme/toolkit",
"items": [
{
"name": "project-conventions",
"type": "registry:item",
"files": [
{
"path": "AGENTS.md",
"type": "registry:file",
"target": "~/AGENTS.md"
},
{
"path": ".editorconfig",
"type": "registry:file",
"target": "~/.editorconfig"
},
{
"path": "docs/conventions.md",
"type": "registry:file",
"target": "~/docs/conventions.md"
}
]
}
]
}
```
## Commands
GitHub registry addresses work with the same commands as other registry
addresses.
List items from a GitHub registry:
```bash
npx shadcn@latest list acme/toolkit
```
Search items:
```bash
npx shadcn@latest search acme/toolkit --query conventions
```
View an item:
```bash
npx shadcn@latest view acme/toolkit/project-conventions
```
Install an item:
```bash
npx shadcn@latest add acme/toolkit/project-conventions
```
See the [GitHub Registries](/docs/registry/github) docs for the full guide.

View File

@@ -0,0 +1,304 @@
---
title: Attachment
description: Displays a file or image attachment with media, metadata, upload state, and actions.
base: base
component: true
---
<ComponentPreview
styleName="base-rhea"
name="attachment-demo"
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
/>
The `Attachment` component displays a file or image attachment, its media, name, and metadata, with optional actions and upload state. Use it for files and images in chat composers, message threads, and upload lists.
## Installation
<CodeTabs>
<TabsList>
<TabsTrigger value="cli">Command</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">
```bash
npx shadcn@latest add attachment
```
</TabsContent>
<TabsContent value="manual">
<Steps className="mb-0 pt-2">
<Step>Install the required shadcn/ui dependencies:</Step>
```bash
npx shadcn@latest add button
```
<Step>Copy and paste the following code into your project.</Step>
<ComponentSource
name="attachment"
title="components/ui/attachment.tsx"
styleName="base-rhea"
/>
<Step>Update the import paths to match your project setup.</Step>
</Steps>
</TabsContent>
</CodeTabs>
## Usage
```tsx
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
```
```tsx
<Attachment>
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove sales-dashboard.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
```
## Composition
Use the following composition to build an attachment:
```text
Attachment
├── AttachmentMedia
├── AttachmentContent
│ ├── AttachmentTitle
│ └── AttachmentDescription
├── AttachmentActions
│ └── AttachmentAction
└── AttachmentTrigger
```
Use `AttachmentGroup` to lay out multiple attachments in a scrollable row:
```text
AttachmentGroup
├── Attachment
└── Attachment
```
## Features
- Icon and image media through `AttachmentMedia`
- Upload states: `idle`, `uploading`, `processing`, `error`, and `done` with built-in styling and a shimmer while in progress
- Three sizes and horizontal or vertical orientation
- A full-card `AttachmentTrigger` that opens a link or dialog while the actions stay independently clickable
- Scrollable, snapping `AttachmentGroup` with an edge fade
- Customizable styling through the `className` prop on every part
## Examples
### Image
Set `variant="image"` on `AttachmentMedia` and render an `<img>` inside it. Use `orientation="vertical"` to stack the media above the content.
<ComponentPreview
styleName="base-rhea"
name="attachment-image"
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
/>
### States
Set `state` to reflect the upload lifecycle. `uploading` and `processing` shimmer the title, and `error` switches to a destructive treatment.
<ComponentPreview
styleName="base-rhea"
name="attachment-states"
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
/>
### Sizes
Use `size` to switch between `default`, `sm`, and `xs`.
<ComponentPreview
styleName="base-rhea"
name="attachment-sizes"
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
/>
### Group
Wrap attachments in `AttachmentGroup` to lay them out in a horizontally scrollable, snapping row with an edge fade.
<ComponentPreview
styleName="base-rhea"
name="attachment-group"
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
/>
### Trigger
Add an `AttachmentTrigger` to make the whole card open a link or dialog. It fills the card behind the actions, so the actions stay clickable.
<ComponentPreview
styleName="base-rhea"
name="attachment-trigger"
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
/>
```tsx showLineNumbers
<Dialog>
<Attachment>
{/* media, content, actions */}
<DialogTrigger
render={<AttachmentTrigger aria-label="Preview research-summary.pdf" />}
/>
</Attachment>
<DialogContent>{/* ... */}</DialogContent>
</Dialog>
```
## Accessibility
`AttachmentAction` renders a `Button`, and `AttachmentTrigger` renders a real `<button>` (or your element via `render`). Follow the guidance below so both are operable and announced.
### Label icon-only actions
`AttachmentAction` is usually icon-only, so give each one an `aria-label` describing the action and its target.
```tsx showLineNumbers
<AttachmentAction aria-label="Remove sales-dashboard.pdf">
<XIcon />
</AttachmentAction>
```
### Label the trigger
`AttachmentTrigger` covers the card with no text of its own, so give it an `aria-label` for what activating it does.
```tsx showLineNumbers
<AttachmentTrigger
render={
<a
href={url}
target="_blank"
rel="noreferrer"
aria-label="Open workspace.png"
/>
}
/>
```
The trigger sits behind the actions in the stacking order, so an `AttachmentAction` and the `AttachmentTrigger` never trap each other — both remain separately focusable and clickable.
### Keyboard scrolling
An `AttachmentGroup` scrolls horizontally. When its attachments are interactive: a trigger or actions, keyboard users reach off-screen items by tabbing to them. For a row of presentational attachments, make the group itself focusable and scrollable by adding `tabIndex={0}`, `role="group"`, and an `aria-label`.
### Meaning beyond color
The `error` state uses a destructive color. Keep the failure reason in `AttachmentDescription` so the state is not conveyed by color alone.
## API Reference
### Attachment
The root attachment container.
| Prop | Type | Default | Description |
| ------------- | ------------------------------------------------------------ | -------------- | ------------------------------------------------- |
| `state` | `"idle" \| "uploading" \| "processing" \| "error" \| "done"` | `"done"` | The upload state. Drives styling and the shimmer. |
| `size` | `"default" \| "sm" \| "xs"` | `"default"` | The attachment size. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Lay the media beside or above the content. |
| `className` | `string` | - | Additional classes to apply to the root element. |
### AttachmentMedia
The media slot for an icon or image preview.
| Prop | Type | Default | Description |
| ----------- | ------------------- | -------- | ---------------------------------------------- |
| `variant` | `"icon" \| "image"` | `"icon"` | Whether the media holds an icon or an `<img>`. |
| `className` | `string` | - | Additional classes to apply to the media slot. |
### AttachmentContent
Wraps the title and description.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the content slot. |
### AttachmentTitle
The attachment name. Shimmers while the attachment is `uploading` or `processing`.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ----------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the title. |
### AttachmentDescription
Secondary metadata such as the file type, size, or upload status.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ----------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the description. |
### AttachmentActions
A container for one or more actions, aligned to the end of the attachment.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the actions. |
### AttachmentAction
An action button. Renders a [`Button`](/docs/components/button) and accepts all of its props.
| Prop | Type | Default | Description |
| ---------- | ------------------------------------- | ----------- | ---------------------------------------- |
| `size` | `Button["size"]` | `"icon-xs"` | The button size. |
| `...props` | `React.ComponentProps<typeof Button>` | - | Props spread to the underlying `Button`. |
### AttachmentTrigger
A full-card overlay that activates the attachment. Renders a `<button>` by default.
| Prop | Type | Default | Description |
| ---------- | -------------------------------- | ------- | ---------------------------------------------- |
| `render` | `ReactElement \| function` | - | Render as a different element, such as a link. |
| `...props` | `React.ComponentProps<"button">` | - | Props spread to the trigger element. |
### AttachmentGroup
Lays out attachments in a horizontally scrollable, snapping row.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ----------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the group. |

View File

@@ -0,0 +1,300 @@
---
title: Bubble
description: Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content.
base: base
component: true
---
<ComponentPreview
styleName="base-rhea"
name="bubble-demo"
previewClassName="h-auto theme-blue"
/>
The `Bubble` component displays framed conversational content. Use it for chat text, short structured output, quoted replies, suggestions, and reactions.
For full-featured chat interfaces, use the [`Message`](/docs/components/message) component. `Bubble` is intentionally scoped to the bubble surface. Place avatars, names, timestamps, metadata, and message-level actions in [`Message`](/docs/components/message).
## Installation
<CodeTabs>
<TabsList>
<TabsTrigger value="cli">Command</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">
```bash
npx shadcn@latest add bubble
```
</TabsContent>
<TabsContent value="manual">
<Steps className="mb-0 pt-2">
<Step>Copy and paste the following code into your project.</Step>
<ComponentSource
name="bubble"
title="components/ui/bubble.tsx"
styleName="base-rhea"
/>
<Step>Update the import paths to match your project setup.</Step>
</Steps>
</TabsContent>
</CodeTabs>
## Usage
```tsx showLineNumbers
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
```
```tsx showLineNumbers
<Bubble>
<BubbleContent>
I checked the registry output and removed the stale route.
</BubbleContent>
<BubbleReactions>
<span>👍</span>
</BubbleReactions>
</Bubble>
```
## Composition
Use the following composition to build a bubble:
```text
Bubble
├── BubbleContent
└── BubbleReactions
```
Use `BubbleGroup` to group consecutive bubbles from the same sender:
```text
BubbleGroup
├── Bubble
│ └── BubbleContent
└── Bubble
└── BubbleContent
```
## Features
- Seven visual variants, from a strong primary bubble to unframed ghost content
- Start and end alignment for sender and receiver bubbles
- Reactions that anchor to the bubble edge with configurable side and alignment
- Bubbles size to their content, up to 80% of the container width
- Polymorphic content via `render` for link and button bubbles
- Customizable styling through the `className` prop on every part
## Examples
### Variants
Use `variant` to change the visual treatment of the bubble.
<ComponentPreview
styleName="base-rhea"
name="bubble-variants"
previewClassName="h-auto theme-blue"
/>
| Variant | Description |
| ------------- | ------------------------------------------------------ |
| `default` | A strong primary bubble, usually for the current user. |
| `secondary` | The standard neutral bubble for conversation content. |
| `muted` | A lower-emphasis bubble for quiet supporting content. |
| `tinted` | A subtle primary-tinted bubble. |
| `outline` | A bordered bubble for secondary or rich content. |
| `ghost` | Unframed content for assistant text or rich content. |
| `destructive` | A destructive bubble for error or failed actions. |
A bubble sizes to its content, up to 80% of the container width. The `ghost` variant removes the max-width so assistant text and rich content can span the full row.
### Alignment
Use `align` on `Bubble` to align the bubble to the start or end of the conversation.
<ComponentPreview
styleName="base-rhea"
name="bubble-alignment"
previewClassName="h-auto theme-blue"
/>
| align | Description |
| ------- | -------------------------------------------------- |
| `start` | Align the bubble to the start of the conversation. |
| `end` | Align the bubble to the end of the conversation. |
**Note:** When building chat interfaces, you probably want to use alignment on the `Message` component itself, not the `Bubble` component. You can use the `role` prop on the `Message` component to automatically align the bubble to the start or end of the conversation.
### Bubble Group
Use `BubbleGroup` to group consecutive bubbles from the same sender. Note the `align` prop should be set on the `Bubble` component itself, not the `BubbleGroup` component.
```text
BubbleGroup
├── Bubble
│ └── BubbleContent
└── Bubble
└── BubbleContent
```
<ComponentPreview
styleName="base-rhea"
name="bubble-group-demo"
previewClassName="h-auto theme-blue"
/>
### Links and Buttons
You can turn a bubble into a link or button by using the `render` prop on `BubbleContent`.
<ComponentPreview
styleName="base-rhea"
name="bubble-link-button"
previewClassName="h-auto theme-blue"
/>
```tsx showLineNumbers
import { Bubble, BubbleContent } from "@/components/ui/bubble"
export function BubbleLinkDemo() {
return (
<Bubble variant="muted">
<BubbleContent render={<button />}>Click here</BubbleContent>
</Bubble>
)
}
```
### Reactions
Use `BubbleReactions` for bubble reactions. You can use it to display reactions or quick action buttons. Use `side` and `align` to position the row — `side="top"` anchors it to the upper edge. Reactions overlap the bubble edge, so leave vertical space between rows — the examples below use a larger `gap` for this reason.
<ComponentPreview
styleName="base-rhea"
name="bubble-reactions"
previewClassName="h-auto theme-blue"
/>
### Show More / Collapsible
Long bubble content can be composed with [`Collapsible`](/docs/components/collapsible) to allow for a show more or show less interaction. Use the `CollapsibleTrigger` component to trigger the collapsible content.
<ComponentPreview
styleName="base-rhea"
name="bubble-collapsible"
previewClassName="h-auto theme-blue"
/>
### Tooltip
Wrap a bubble in a [`Tooltip`](/docs/components/tooltip) to reveal metadata on hover, such as when a message was read.
<ComponentPreview
styleName="base-rhea"
name="bubble-tooltip"
previewClassName="h-auto theme-blue"
/>
### Popover
Pair a bubble with a [`Popover`](/docs/components/popover) to surface more information on demand, such as the full error message for a failed action.
<ComponentPreview
styleName="base-rhea"
name="bubble-popover"
previewClassName="h-auto theme-blue"
/>
## Accessibility
`Bubble` renders the presentational message surface. Keep conversation-level semantics on the surrounding container and follow the guidelines below.
### Labeling Reactions
Reactions render as a row of emoji. A screen reader reads each glyph with no context, and counters like `+8` are announced as "plus eight". Group the row as a single image with a descriptive `aria-label` so it announces once. `role="img"` also hides the individual emoji from assistive tech, so no `aria-hidden` is needed.
```tsx showLineNumbers
<BubbleReactions role="img" aria-label="Reactions: thumbs up, fire, and 8 more">
<span>👍</span>
<span>🔥</span>
<span>+8</span>
</BubbleReactions>
```
When reactions are interactive, render buttons instead and give icon-only buttons an `aria-label`.
```tsx showLineNumbers
<BubbleReactions>
<Button aria-label="Thumbs up" variant="secondary" size="icon-xs">
<ThumbsUpIcon />
</Button>
</BubbleReactions>
```
### Interactive Bubbles
When a bubble is clickable, render it as a real `<button>` or `<a>` with the `render` prop so it is focusable and exposes the correct role. `BubbleContent` ships a visible focus ring for interactive elements, and the accessible name comes from the bubble text. No extra label is needed.
```tsx showLineNumbers
<Bubble variant="muted" align="end">
<BubbleContent render={<button type="button" onClick={onReply} />}>
I forgot my password
</BubbleContent>
</Bubble>
```
### Meaning Beyond Color
Bubble variants signal role and tone with color. Pair them with text, alignment, or icons so meaning is not conveyed by color alone. For a `destructive` bubble, keep the error context in the message text rather than relying on the color treatment.
## API Reference
### Bubble
The root bubble wrapper.
| Prop | Type | Default | Description |
| ----------- | ------------------------------------------------------------------------------------------ | ----------- | ------------------------------------------------ |
| `variant` | `"default" \| "secondary" \| "muted" \| "tinted" \| "outline" \| "ghost" \| "destructive"` | `"default"` | The bubble visual treatment. |
| `align` | `"start" \| "end"` | `"start"` | The inline alignment of the bubble. |
| `className` | `string` | - | Additional classes to apply to the root element. |
### BubbleContent
The bubble content wrapper.
| Prop | Type | Default | Description |
| ----------- | -------------------------- | ------- | --------------------------------------------------------- |
| `render` | `ReactElement \| function` | - | Render the content as a different element such as a link. |
| `className` | `string` | - | Additional classes to apply to the content element. |
### BubbleReactions
Displays overlapped reactions for a bubble.
| Prop | Type | Default | Description |
| ----------- | ------------------- | ---------- | ------------------------------------------------ |
| `side` | `"top" \| "bottom"` | `"bottom"` | The side of the bubble to anchor the reactions. |
| `align` | `"start" \| "end"` | `"end"` | The inline alignment of the reactions. |
| `className` | `string` | - | Additional classes to apply to the reaction row. |
### BubbleGroup
Groups consecutive bubbles from the same sender.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ---------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the group root. |

View File

@@ -103,6 +103,24 @@ Use the `size="sm"` prop to set the size of the card to small. The small size va
previewClassName="h-96" previewClassName="h-96"
/> />
### Spacing
In addition to the `size` prop, you can use the `--card-spacing` CSS variable to control the spacing between sections and the inset of card parts.
<ComponentPreview
styleName="base-nova"
name="card-spacing"
previewClassName="h-[34rem]"
/>
Use negative margins with `-mx-(--card-spacing)` to make content go edge to edge while keeping it aligned with the card inset. When the edge-to-edge content sits above a footer, use `-mb-(--card-spacing)` on `CardContent` to remove the section gap.
<ComponentPreview
styleName="base-nova"
name="card-edge-to-edge"
previewClassName="h-[24rem]"
/>
### Image ### Image
Add an image before the card header to create a card with an image. Add an image before the card header to create a card with an image.
@@ -182,3 +200,70 @@ The `CardFooter` component is used for actions and secondary content at the bott
| Prop | Type | Default | | Prop | Type | Default |
| ----------- | -------- | ------- | | ----------- | -------- | ------- |
| `className` | `string` | - | | `className` | `string` | - |
## Changelog
### Spacing Variable
If you're upgrading from a previous version of the `Card` component, you'll need to apply the following updates to use the `--card-spacing` variable:
<Steps>
<Step>Update the Card root spacing classes.</Step>
Replace the hard-coded gap and vertical padding with `--card-spacing`, and set the default and small size values on the root:
```diff
className={cn(
- "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
```
<Step>Update CardHeader spacing classes.</Step>
Replace the horizontal padding and border spacing with the shared variable:
```diff
className={cn(
- "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
+ "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
```
<Step>Update CardContent and CardFooter spacing classes.</Step>
Use `--card-spacing` for the content inset and footer padding:
```diff
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
- className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
+ className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
```
```diff
className={cn(
- "flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
+ "flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
```
</Steps>
After applying these changes, you can customize card spacing by setting `--card-spacing` on the `Card` with an arbitrary property class:
```tsx
function Example() {
return <Card className="[--card-spacing:--spacing(6)]">...</Card>
}
```

View File

@@ -0,0 +1,273 @@
---
title: Marker
description: Displays an inline status, system note, bordered row, or labeled separator in a conversation.
base: base
component: true
---
<ComponentPreview
styleName="base-rhea"
name="marker-demo"
previewClassName="h-auto theme-blue"
/>
The `Marker` component displays inline conversation markers such as status updates, system notes, bordered rows, and labeled separators. Compose it with [`Message`](/docs/components/message) in a conversation thread.
## Installation
<CodeTabs>
<TabsList>
<TabsTrigger value="cli">Command</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">
```bash
npx shadcn@latest add marker
```
</TabsContent>
<TabsContent value="manual">
<Steps className="mb-0 pt-2">
<Step>Copy and paste the following code into your project.</Step>
<ComponentSource
name="marker"
title="components/ui/marker.tsx"
styleName="base-rhea"
/>
<Step>Update the import paths to match your project setup.</Step>
</Steps>
</TabsContent>
</CodeTabs>
## Usage
```tsx showLineNumbers
import { Marker, MarkerContent, MarkerIcon } from "@/components/ui/marker"
```
```tsx showLineNumbers
<Marker>
<MarkerIcon>
<CheckIcon />
</MarkerIcon>
<MarkerContent>Explored 4 files</MarkerContent>
</Marker>
```
## Composition
Use the following composition to build a marker:
```text
Marker
├── MarkerIcon
└── MarkerContent
```
## Features
- Inline marker, bordered row, and labeled separator variants
- Decorative icon slot that is hidden from assistive tech
- Polymorphic root via `render` for link and button markers
- Pairs with the [`shimmer`](/docs/utils/shimmer) utility for streaming status text
- Customizable styling through the `className` prop on every part
## Examples
### Variants
Use `variant` to switch between an inline marker, bordered row, and labeled separator.
<ComponentPreview
styleName="base-rhea"
name="marker-variants"
previewClassName="h-auto theme-blue"
/>
| Variant | Description |
| ----------- | ---------------------------------------------------- |
| `default` | An inline marker for status, notes, and actions. |
| `border` | A default marker with a bottom border under the row. |
| `separator` | A centered label with divider lines on each side. |
### Status
Set `role="status"` and include a [`Spinner`](/docs/components/spinner) for streaming or in-progress markers so updates are announced.
<ComponentPreview
styleName="base-rhea"
name="marker-status"
previewClassName="h-auto theme-blue"
/>
### Shimmer
Add the [`shimmer`](/docs/utils/shimmer) utility class to `MarkerContent` for an animated streaming-text effect. The utility ships with the `shadcn` package — see the shimmer docs for installation.
<ComponentPreview
styleName="base-rhea"
name="marker-shimmer"
previewClassName="h-auto theme-blue"
/>
### Separator
Use the `separator` variant for labeled dividers, such as dates or section breaks, in a conversation.
<ComponentPreview
styleName="base-rhea"
name="marker-separator"
previewClassName="h-auto theme-blue"
/>
### Border
Use the `border` variant for status rows that should keep the default marker alignment while separating the next row.
<ComponentPreview
styleName="base-rhea"
name="marker-border"
previewClassName="h-auto theme-blue"
/>
### With Icon
Use `MarkerIcon` to render an icon alongside the content. Use `flex-col` to stack the icon above the content.
<ComponentPreview
styleName="base-rhea"
name="marker-icon"
previewClassName="h-auto theme-blue"
/>
### Links and Buttons
Turn a marker into a link or button with the `render` prop on `Marker`.
<ComponentPreview
styleName="base-rhea"
name="marker-link-button"
previewClassName="h-auto theme-blue"
/>
```tsx showLineNumbers
import { Marker, MarkerContent } from "@/components/ui/marker"
export function MarkerLinkDemo() {
return (
<Marker render={<a href="#" />}>
<MarkerContent>View the pull request</MarkerContent>
</Marker>
)
}
```
## Accessibility
`Marker` is presentational by default. The correct semantics depend on how you use it, so choose the role based on intent rather than relying on a single default.
### Status and Progress
For streaming or progress markers such as "Thinking..." or a running tool, set `role="status"` so assistive tech announces the update as it appears. `Marker` forwards `role` to the underlying element.
```tsx showLineNumbers
<Marker role="status">
<MarkerIcon>
<Spinner />
</MarkerIcon>
<MarkerContent>Compacting conversation</MarkerContent>
</Marker>
```
### Labeled Separators
A separator that carries text, such as a date or a section label, needs no role. The divider lines are decorative CSS pseudo-elements, and the text is announced as ordinary content.
```tsx showLineNumbers
<Marker variant="separator">
<MarkerContent>Today</MarkerContent>
</Marker>
```
<Callout>
**Note:** Do not add `role="separator"` to a labeled divider. A separator
takes its accessible name from `aria-label`, not from its text, and its
contents are treated as presentational, so the visible label would not be
announced. Reserve `role="separator"` for a divider with no meaningful text.
</Callout>
### Bordered Markers
A bordered marker keeps the same semantics as the default marker. The bottom border is decorative, so choose `role="status"`, `render`, or no role based on the marker's purpose.
```tsx showLineNumbers
<Marker variant="border">
<MarkerIcon>
<FileTextIcon />
</MarkerIcon>
<MarkerContent>Opened implementation notes</MarkerContent>
</Marker>
```
### Decorative Icons
`MarkerIcon` is decorative and hidden from assistive tech with `aria-hidden`, so the adjacent `MarkerContent` carries the meaning. For an icon-only marker, provide an `aria-label` or visible text so it is not announced as empty.
```tsx showLineNumbers
<Marker aria-label="Synced">
<MarkerIcon>
<CheckIcon />
</MarkerIcon>
</Marker>
```
### Interactive Markers
When a marker links or triggers an action, render it as a real `<button>` or `<a>` with the `render` prop so it is focusable and exposes the correct role. The accessible name comes from the marker text.
```tsx showLineNumbers
<Marker render={<a href="/files" />}>
<MarkerIcon>
<FileTextIcon />
</MarkerIcon>
<MarkerContent>Explored 4 files</MarkerContent>
</Marker>
```
## API Reference
### Marker
The root marker element. The file also exports `markerVariants` for composing the marker styles into custom components.
| Prop | Type | Default | Description |
| ----------- | -------------------------------------- | ----------- | ------------------------------------------------ |
| `variant` | `"default" \| "border" \| "separator"` | `"default"` | The marker layout. |
| `render` | `ReactElement \| function` | - | Render as a different element, such as a link. |
| `className` | `string` | - | Additional classes to apply to the root element. |
### MarkerIcon
A decorative icon slot. Hidden from assistive tech with `aria-hidden`.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | --------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the icon slot. |
### MarkerContent
The marker text content.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the content slot. |

View File

@@ -0,0 +1,586 @@
---
title: Message Scroller
description: A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.
base: base
component: true
---
<ComponentPreview
styleName="base-rhea"
name="message-scroller-demo"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
## What Makes a Great Streaming Chat Experience
Building a chat interface used to be simple. You create an inverted list with
an input. Type a message, it appends at the bottom. When a reply comes in, the
list grows and scrolls. Done.
Streaming breaks that model. Messages arrive in chunks while you may still be
reading, scrolling, or looking somewhere else entirely.
Now the challenge is preserving the reader's place while the conversation keeps
changing. Get that wrong and the experience feels jumpy: people are pulled to
the bottom, lose context, and have to find their way back.
In practice, this comes down to scroll: when to follow, when to hold, and when
to let the reader decide. A great streaming chat should:
1. **Move only when the reader asked to move.** If someone is reading, dont pull them somewhere else. Auto-scroll should never be the default.
2. **Follow only while theyre following.** If theyre at the live edge, keep the stream in view. If they scroll away, leave them there.
3. **Every interaction is a signal.** Scrolling is not the only one. Selecting text, using the keyboard, opening a link, or searching should all stop the interface from moving.
4. **Start a new turn near the top of the viewport.** This gives the new turn somewhere it can be read from the beginning.
5. **Then stream in the answer.** The answer should grow into the screen, not immediately push everything away.
6. **Keep part of the previous conversation in context.** The prompt and reply should stay visually connected, and enough of the previous turn should remain visible so the reader knows where they are.
7. **Let new content arrive offscreen.** The conversation can keep streaming without changing what the reader is looking at.
8. **Show whats happening out of view.** Make it clear when a response is still streaming or when new messages have arrived.
9. **Make it easy to return to the latest reply.** A “Jump to latest” action should bring the reader back and resume following.
10. **Let people jump anywhere in the conversation.** Long threads need message links, search, unread markers, and direct navigation.
11. **Reopen where the reader left off.** A saved conversation should open at the last meaningful turn. Often this is the last user message. Not the absolute bottom.
12. **Keep the readers place when layout changes.** Images load. Markdown expands. Code blocks render. Older messages appear above. None of that should make the reader lose their place.
13. **Handle interruptions without stealing position.** Stopping, retrying, regenerating, branching, or errors should not unexpectedly move the conversation.
14. **Stay responsive in long threads.** Streaming text, markdown, code, images, and long history should still feel responsive.
15. **Be accessible without the noise.** Keep the transcript navigable, preserve keyboard focus, and announce important events at a comfortable pace.
**Never move the reader against their intent.**
## MessageScroller
MessageScroller is a chat transcript scroller built for these behaviors.
`MessageScrollerProvider` owns the scroll state and transcript-row behavior:
opening position, streamed output, new-turn anchoring, prepended history,
visibility, and scroll controls. `MessageScroller` is the styled frame that
renders inside it.
MessageScroller is scoped to the scroll viewport. It does not own messages, AI state,
transport, persistence, branching, or model state. Your product code stays
focused on composing messages, markers, tools, attachments, and prompt inputs.
It gives you the scroll behavior that chat needs, without taking over the rest
of the chat UI. And it stays fast, even in long conversations with rich
markdown.
## Installation
<CodeTabs>
<TabsList>
<TabsTrigger value="cli">Command</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">
```bash
npx shadcn@latest add message-scroller
```
</TabsContent>
<TabsContent value="manual">
<Steps className="mb-0 pt-2">
<Step>Install the following dependencies:</Step>
```bash
npm install @shadcn/react
```
<Step>Copy and paste the following code into your project.</Step>
<ComponentSource
name="message-scroller"
title="components/ui/message-scroller.tsx"
styleName="base-nova"
/>
<Step>Update the import paths to match your project setup.</Step>
</Steps>
</TabsContent>
</CodeTabs>
## Usage
```tsx
import { Message } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
```
```tsx
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
<Message />
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
```
`MessageScroller` fills its parent, so place it inside a height-constrained
container.
```tsx
<div className="flex h-screen flex-col">
<MessageScrollerProvider>
<MessageScroller className="flex-1">{/* transcript */}</MessageScroller>
</MessageScrollerProvider>
</div>
```
## Composition
```tsx
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
<MessageScrollerItem>
{/* a message, marker, or row */}
</MessageScrollerItem>
<MessageScrollerItem />
<MessageScrollerItem />
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
```
- **`MessageScrollerProvider`** — the headless root. Owns scroll state and the
behavior props for opening position, auto-scroll, anchoring, scroll commands,
and visibility tracking.
- **`MessageScroller`** — the styled frame. Lays out the viewport, content, and
controls inside the provider.
- **`MessageScrollerViewport`** — the scrollable element. Receives native scroll
events and preserves the visible row when older messages are prepended.
- **`MessageScrollerContent`** — the transcript container. Holds the rows and
provides the live-region defaults for new messages.
- **`MessageScrollerItem`** — the transcript row boundary. Wrap every direct
child of the content so the scroller can measure, anchor, preserve position,
track visibility, and jump to it. An item can be a message, marker, typing
indicator, separator, join/leave event, or "load earlier" row.
- **`MessageScrollerButton`** — the scroll control. Scrolls to the start or end of the transcript and is inert until there is content in its direction.
## Core Concepts
### Anchoring Turns
A turn is the part of the conversation that starts a new exchange. In a simple
AI chat, that is usually the user's message and the assistant reply that follows.
An anchor is the row the viewport should treat as the start of that turn. Mark
that row with `scrollAnchor`. When a new anchor is appended, the viewport moves
it near the top and keeps a peek of the previous item above it, so the new turn
does not feel detached from its context.
```tsx
// This tells the scroller to anchor the user's message for the next turn.
<MessageScrollerItem
messageId={message.id}
scrollAnchor={message.role === "user"}
/>
```
Scroll anchors are not tied to message role. You can turn any row into an anchor:
a user message, a system marker, a handoff event, or anything else that starts a
meaningful turn. `MessageScroller` only needs to know which row should anchor the
viewport.
In the following example, the user's message is anchored. When you send a new message, the viewport anchors it near the top and appends the assistant reply below it. Toggle the anchor to the assistant's message to see the difference.
<ComponentPreview
styleName="base-rhea"
name="message-scroller-anchoring"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
### Group Chat
In a group chat, the turn boundary is more specific than "the user message". It is often
the message that asks the model to respond, or a marker like "Marcus joined the
chat". Typing indicators and history controls usually should not anchor.
Because anchoring is role-independent, you can anchor a marker just as easily as
a message.
```tsx
<MessageScrollerItem messageId="marcus-joined" scrollAnchor>
<Marker variant="separator">
<MarkerContent>Marcus joined the chat</MarkerContent>
</Marker>
</MessageScrollerItem>
```
<ComponentPreview
styleName="base-rhea"
name="message-scroller-group-chat"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
### Keeping Context Visible
When a new turn starts, it should still feel like part of the same continuous
thread. `scrollPreviousItemPeek` keeps a slice of the previous item visible
above the anchor, so the reader keeps their context instead of feeling like the
conversation restarted on a blank page.
```tsx
// Keep 64px of the previous turn visible above the newly anchored row.
<MessageScrollerProvider scrollPreviousItemPeek={64}>
<MessageScroller>{/* anchored turns */}</MessageScroller>
</MessageScrollerProvider>
```
Adjust the peek amount in the example below to see how it affects the conversation.
<ComponentPreview
styleName="base-rhea"
name="message-scroller-previous-context"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
### Following the Live Edge
When the reader is at the live edge, either because they stayed there or
returned there, `autoScroll` keeps streamed replies in view as they grow.
Scrolling away from the live edge releases the view, whether by wheel, touch,
keyboard scroll keys, or dragging the scrollbar. An explicit message jump
releases it too. New chunks can then arrive without moving the reader.
```tsx
<MessageScrollerProvider autoScroll>
<MessageScroller>{/* streamed turns */}</MessageScroller>
</MessageScrollerProvider>
```
<ComponentPreview
styleName="base-rhea"
name="message-scroller-streaming"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
Calling `scrollToEnd`, or pressing `MessageScrollerButton`, re-engages
follow-output when `autoScroll` is enabled, so a reader who scrolled away can
return to the live edge and keep following. The root and viewport expose
`data-autoscrolling` while that programmatic scroll to the latest message runs,
so you can conditionally apply styles during the transition.
### Opening Saved Threads
It can seem reasonable to reopen a saved thread at the absolute end of the
transcript, but that often drops the reader into the conversation without enough
context. A better default is `"last-anchor"`: show the last meaningful turn,
like the user's latest message, with the reply below it.
That gives the reader an immediate place in the thread. They can see what they
asked, where the answer starts, and continue from there without reconstructing
the conversation from the bottom edge.
```tsx
<MessageScrollerProvider defaultScrollPosition="last-anchor">
<MessageScroller>{/* transcript */}</MessageScroller>
</MessageScrollerProvider>
```
<ComponentPreview
styleName="base-rhea"
name="message-scroller-opening-position"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
hideCode
/>
`"last-anchor"` is keyed on `scrollAnchor`, not message role. If no anchor
exists, or the last anchored turn already fits in the viewport, it falls back to
`"end"`.
Use `"start"` when you want to resume at the beginning of a conversation, or
`"end"` when the absolute latest message is the right place to land.
### Loading Earlier Messages
Loading earlier messages should not move the conversation the reader is already
looking at. When older rows are prepended above the current transcript,
`MessageScrollerViewport` preserves the visible row so the reader stays in the
same place while history loads above them.
This is enabled by default through `preserveScrollOnPrepend`.
<ComponentPreview
styleName="base-rhea"
name="message-scroller-load-history"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
Use stable `messageId` values for message rows. That gives the scroller a
specific row to preserve instead of guessing from whichever pixel happens to sit
at the viewport edge.
### Animating New Messages
`MessageScrollerItem` can be animated directly. Create a motion version of the
item, keep `messageId` and `scrollAnchor` on it, and use transform and opacity
for the entrance.
A common chat pattern is to animate the user's message when it is sent, then let
the assistant reply stream into a regular row below it. Start the user row below
its final position so it feels like it rises from the live edge of the viewport.
```tsx
const MotionMessageScrollerItem = motion.create(MessageScrollerItem)
```
<ComponentPreview
styleName="base-rhea"
name="message-scroller-animation"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
Avoid animating height, margin, or padding for row entrances; those changes can
fight the scroller's positioning work. If the reader prefers reduced motion,
skip the entrance animation and keep the scroll behavior the same.
### Jumping to Messages
Search results, permalinks, outline items, and toolbar buttons often need to
drive the transcript from outside the message list. Use `useMessageScroller` for
those controls. Because the hooks read from `MessageScrollerProvider`, they work
in any component inside the provider, including controls rendered outside the
`MessageScroller` frame.
```tsx
import { useMessageScroller } from "@/components/ui/message-scroller"
```
```tsx
const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller()
```
<ComponentPreview
styleName="base-rhea"
name="message-scroller-commands"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
hideCode
/>
`scrollToMessage` targets the `messageId` on `MessageScrollerItem`, so rows that
need to be addressable should have stable ids. `scrollToMessage` returns `false`
when the target is not mounted and cannot be queued.
`scrollToMessage` can queue a target before items exist, which covers
client-resolved permalinks while the transcript mounts. After rows have mounted,
a missing id returns `false` instead of starting a guessed retry loop. A `true`
result means the scroll ran or was queued, not that the row is already in view.
### Tracking the Reader's Position
Use `useMessageScrollerVisibility` to track the reader's position in the
conversation. A common example is a table-of-contents or a jump menu that
highlights the current anchored turn.
```tsx
import { useMessageScrollerVisibility } from "@/components/ui/message-scroller"
```
```tsx
const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()
```
<ComponentPreview
styleName="base-rhea"
name="message-scroller-visibility"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
hideCode
/>
`currentAnchorId` answers "where am I" by reporting the current anchored turn,
and it stays set after that anchor scrolls above the viewport. `visibleMessageIds`
answers "what is on screen", in document order.
Visibility is pay-for-what-you-use. Tracking only runs while something
subscribes to `useMessageScrollerVisibility`, and rows need a `messageId` to
participate.
### Reading Scroll State
Use `useMessageScrollerScrollable` when you need scroll state in JavaScript, such
as a status indicator or a custom "jump to latest" control. It reports which
edges the viewport can still scroll toward; "at the start/end" is the negation
(`!start` / `!end`), and "scrollable at all" is `start || end`. For styling the
scroller itself, prefer the `data-scrollable` attribute.
```tsx
import { useMessageScrollerScrollable } from "@/components/ui/message-scroller"
```
```tsx
const { start, end } = useMessageScrollerScrollable()
```
<ComponentPreview
styleName="base-rhea"
name="message-scroller-scrollable"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
## Performance
`MessageScroller` is benchmarked against large transcripts with markdown and
composed message rows.
Our performance goal for `MessageScroller` is to keep the scroll hot path outside of React state: no React rerenders for
transcript rows, no forced layout on every scroll, and as little off-screen paint
work as the browser can avoid.
Scroll position, anchoring, and follow-output are tracked imperatively and mirrored onto the root and viewport through `data-*` attributes, so scrolling and streaming do not rerender transcript rows.
The styled `MessageScrollerItem` also ships with `content-visibility: auto` and
`contain-intrinsic-size`. Rows stay in the DOM for selection, copy,
find-in-page, SSR, and assistive tech, but the browser can skip rendering work
for rows far outside the viewport.
Visibility tracking is pay-for-what-you-use. A jump menu or active
turn indicator costs nothing until something subscribes to
`useMessageScrollerVisibility`.
This is comfortable for the expected range of a chat transcript: hundreds to low
thousands of turns, including messages with markdown and composed components.
## Virtualization
Virtualization is intentionally left outside the primitive. `MessageScroller`
renders real DOM rows and stays fast well into the thousands of turns (see
[Performance](#performance)), so most transcripts never need it.
When a transcript is large enough to need virtualization, use
`MessageScrollerViewport` as the scroll element and let the virtualizer own the
rows.
```tsx showLineNumbers
import * as React from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
function VirtualizedTranscript({
messages,
}: {
messages: Array<{ id: string; content: React.ReactNode }>
}) {
const viewportRef = React.useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => viewportRef.current,
estimateSize: () => 86,
getItemKey: (index) => messages[index]?.id ?? index,
overscan: 8,
})
return (
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport ref={viewportRef}>
<MessageScrollerContent className="block min-h-full">
<div
className="relative w-full"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const message = messages[virtualItem.index]
if (!message) {
return null
}
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className="absolute start-0 top-0 w-full"
style={{
transform: `translateY(${virtualItem.start}px)`,
}}
>
<Message>{message.content}</Message>
</div>
)
})}
</div>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
)
}
```
## Accessibility
`MessageScroller` keeps the scroll container keyboard reachable and the
transcript announceable without forcing a specific message UI.
`MessageScrollerViewport` is a labelled, keyboard-focusable scroll region by
default. It uses `role="region"`, `aria-label="Messages"`, and `tabIndex={0}`,
so keyboard users can focus the transcript and scroll it directly.
`MessageScrollerContent` marks the transcript as a live region with
`role="log"` and `aria-relevant="additions"`. New rows can be announced, but
streamed text mutations do not have to be announced token by token.
```tsx
<MessageScrollerContent aria-busy={status === "streaming"}>
{/* messages */}
</MessageScrollerContent>
```
Pass `aria-busy` while a turn streams if announcements should wait for the
completed message row.
`MessageScrollerButton` renders a real button. When there is nothing to scroll
toward, it sets `inert`, uses `tabIndex={-1}`, and exposes `data-active="false"`
so inactive scroll controls do not create extra focus stops.
## Unstyled
The behavior in `MessageScroller` comes from the `@shadcn/react` package. To use
it directly with your own markup and styles, see
[Message Scroller](/docs/react/message-scroller) under @shadcn/react.
## API Reference
The props, data attributes, and hooks for every part are documented on the
[@shadcn/react Message Scroller](/docs/react/message-scroller#api-reference) page.
They are identical for the styled component and the unstyled parts.

View File

@@ -0,0 +1,248 @@
---
title: Message
description: Displays a message in a conversation, with optional avatar, header, footer, and alignment.
base: base
component: true
---
<ComponentPreview
styleName="base-rhea"
name="message-demo"
previewClassName="h-auto theme-blue"
/>
The `Message` component lays out a single message in a conversation. It handles the avatar, alignment, header, and footer around the message surface.
For AI apps, you can render reasoning steps, tool calls and assistant messages using the `Message` component.
## Installation
<CodeTabs>
<TabsList>
<TabsTrigger value="cli">Command</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">
```bash
npx shadcn@latest add message
```
</TabsContent>
<TabsContent value="manual">
<Steps className="mb-0 pt-2">
<Step>Copy and paste the following code into your project.</Step>
<ComponentSource
name="message"
title="components/ui/message.tsx"
styleName="base-rhea"
/>
<Step>Update the import paths to match your project setup.</Step>
</Steps>
</TabsContent>
</CodeTabs>
## Usage
```tsx showLineNumbers
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Message, MessageAvatar, MessageContent } from "@/components/ui/message"
```
```tsx showLineNumbers
<Message>
<MessageAvatar>
<Avatar>
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<Bubble>
<BubbleContent>How can I help you today?</BubbleContent>
</Bubble>
</MessageContent>
</Message>
```
**Note:** `Message` owns the row layout—avatar, alignment, header, and footer.
Render the visible message surface inside it with
[`Bubble`](/docs/components/bubble). For the scroll container around a
conversation, use [`MessageScroller`](/docs/components/message-scroller).
## Composition
Use the following composition to build a message:
```text
Message
├── MessageAvatar
└── MessageContent
├── MessageHeader
├── Bubble
└── MessageFooter
```
Use `MessageGroup` to stack consecutive messages from the same sender:
```text
MessageGroup
├── Message
└── Message
```
## Features
- Start and end alignment for sender and receiver rows via the `align` prop
- Avatar slot that anchors to the bottom of the message and stays clear of the footer
- Header and footer slots for sender names, status, and message actions
- Footer follows the message side; actions stay aligned on `align="end"` rows
- Group wrapper for stacking consecutive messages from the same sender
- Customizable styling through the `className` prop on every part
## Examples
### Avatar
Use `MessageAvatar` to render an avatar next to the message. Set `align="end"` on the message to align the avatar to the end of the message.
<ComponentPreview
styleName="base-rhea"
name="message-avatar"
previewClassName="h-auto theme-blue"
/>
| align | Description |
| ------- | --------------------------------------------------- |
| `start` | Align the message to the start of the conversation. |
| `end` | Align the message to the end of the conversation. |
### Group
Use `MessageGroup` to stack consecutive messages from the same sender. Render an empty `MessageAvatar` on the earlier messages to keep them aligned with the avatar on the last one.
<ComponentPreview
styleName="base-rhea"
name="message-group"
previewClassName="h-auto theme-blue"
/>
### Header and Footer
Use `MessageHeader` for a sender name and `MessageFooter` for metadata such as a delivery or read status.
<ComponentPreview
styleName="base-rhea"
name="message-header-footer"
previewClassName="h-auto theme-blue"
/>
### Actions
Place message-level actions in `MessageFooter`, such as copy, retry, or feedback buttons.
<ComponentPreview
styleName="base-rhea"
name="message-actions"
previewClassName="h-auto theme-blue"
/>
### Attachment
<ComponentPreview
styleName="base-rhea"
name="message-attachment"
previewClassName="h-auto theme-blue"
/>
## Accessibility
`Message` is a presentational layout wrapper. Accessibility comes from the content you place inside it.
### Label icon-only actions
Action buttons in `MessageFooter` are usually icon-only, so give each one an `aria-label`.
```tsx showLineNumbers
<MessageFooter>
<Button variant="ghost" size="icon" aria-label="Copy">
<CopyIcon />
</Button>
</MessageFooter>
```
### Status updates
For in-progress messages, use a [`Marker`](/docs/components/marker) with `role="status"` so assistive tech announces the update as it appears.
```tsx showLineNumbers
<Message>
<Marker role="status">
<MarkerIcon>
<Spinner />
</MarkerIcon>
<MarkerContent>Checking the logs...</MarkerContent>
</Marker>
</Message>
```
## API Reference
### Message
The message row wrapper.
| Prop | Type | Default | Description |
| ----------- | ------------------ | --------- | ------------------------------------------------- |
| `align` | `"start" \| "end"` | `"start"` | The alignment of the message in the conversation. |
| `className` | `string` | - | Additional classes to apply to the row. |
### MessageGroup
Groups consecutive messages from the same sender.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ---------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the group root. |
### MessageAvatar
The avatar slot, aligned to the bottom of the message. When the message has a `MessageFooter`, the avatar shifts up to stay aligned with the message surface instead of the footer.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ----------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the avatar slot. |
### MessageContent
Wraps the header, message surface, and footer.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the content slot. |
### MessageHeader
Displays content above the message, such as a sender name. Stays aligned to the start regardless of `align`.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the header. |
### MessageFooter
Displays content below the message, such as status or actions. Aligns to the message side.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the footer. |

View File

@@ -5,9 +5,11 @@
"alert", "alert",
"alert-dialog", "alert-dialog",
"aspect-ratio", "aspect-ratio",
"attachment",
"avatar", "avatar",
"badge", "badge",
"breadcrumb", "breadcrumb",
"bubble",
"button", "button",
"button-group", "button-group",
"calendar", "calendar",
@@ -35,7 +37,10 @@
"item", "item",
"kbd", "kbd",
"label", "label",
"marker",
"menubar", "menubar",
"message",
"message-scroller",
"native-select", "native-select",
"navigation-menu", "navigation-menu",
"pagination", "pagination",

View File

@@ -3,6 +3,12 @@ title: Components
description: Here you can find all the components available in the library. We are working on adding more components. description: Here you can find all the components available in the library. We are working on adding more components.
--- ---
## New Components
<ComponentsList variant="new" />
## All Components
<ComponentsList /> <ComponentsList />
--- ---

View File

@@ -0,0 +1,302 @@
---
title: Attachment
description: Displays a file or image attachment with media, metadata, upload state, and actions.
base: radix
component: true
---
<ComponentPreview
styleName="radix-rhea"
name="attachment-demo"
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
/>
The `Attachment` component displays a file or image attachment, its media, name, and metadata, with optional actions and upload state. Use it for files and images in chat composers, message threads, and upload lists.
## Installation
<CodeTabs>
<TabsList>
<TabsTrigger value="cli">Command</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">
```bash
npx shadcn@latest add attachment
```
</TabsContent>
<TabsContent value="manual">
<Steps className="mb-0 pt-2">
<Step>Install the required shadcn/ui dependencies:</Step>
```bash
npx shadcn@latest add button
```
<Step>Copy and paste the following code into your project.</Step>
<ComponentSource
name="attachment"
title="components/ui/attachment.tsx"
styleName="radix-rhea"
/>
<Step>Update the import paths to match your project setup.</Step>
</Steps>
</TabsContent>
</CodeTabs>
## Usage
```tsx
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
```
```tsx
<Attachment>
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove sales-dashboard.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
```
## Composition
Use the following composition to build an attachment:
```text
Attachment
├── AttachmentMedia
├── AttachmentContent
│ ├── AttachmentTitle
│ └── AttachmentDescription
├── AttachmentActions
│ └── AttachmentAction
└── AttachmentTrigger
```
Use `AttachmentGroup` to lay out multiple attachments in a scrollable row:
```text
AttachmentGroup
├── Attachment
└── Attachment
```
## Features
- Icon and image media through `AttachmentMedia`
- Upload states: `idle`, `uploading`, `processing`, `error`, and `done` with built-in styling and a shimmer while in progress
- Three sizes and horizontal or vertical orientation
- A full-card `AttachmentTrigger` that opens a link or dialog while the actions stay independently clickable
- Scrollable, snapping `AttachmentGroup` with an edge fade
- Customizable styling through the `className` prop on every part
## Examples
### Image
Set `variant="image"` on `AttachmentMedia` and render an `<img>` inside it. Use `orientation="vertical"` to stack the media above the content.
<ComponentPreview
styleName="radix-rhea"
name="attachment-image"
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
/>
### States
Set `state` to reflect the upload lifecycle. `uploading` and `processing` shimmer the title, and `error` switches to a destructive treatment.
<ComponentPreview
styleName="radix-rhea"
name="attachment-states"
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
/>
### Sizes
Use `size` to switch between `default`, `sm`, and `xs`.
<ComponentPreview
styleName="radix-rhea"
name="attachment-sizes"
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
/>
### Group
Wrap attachments in `AttachmentGroup` to lay them out in a horizontally scrollable, snapping row with an edge fade.
<ComponentPreview
styleName="radix-rhea"
name="attachment-group"
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
/>
### Trigger
Add an `AttachmentTrigger` to make the whole card open a link or dialog. It fills the card behind the actions, so the actions stay clickable.
<ComponentPreview
styleName="radix-rhea"
name="attachment-trigger"
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
/>
```tsx showLineNumbers
<Dialog>
<Attachment>
{/* media, content, actions */}
<DialogTrigger asChild>
<AttachmentTrigger aria-label="Preview research-summary.pdf" />
</DialogTrigger>
</Attachment>
<DialogContent>{/* ... */}</DialogContent>
</Dialog>
```
## Accessibility
`AttachmentAction` renders a `Button`, and `AttachmentTrigger` renders a real `<button>` (or your element via `asChild`). Follow the guidance below so both are operable and announced.
### Label icon-only actions
`AttachmentAction` is usually icon-only, so give each one an `aria-label` describing the action and its target.
```tsx showLineNumbers
<AttachmentAction aria-label="Remove sales-dashboard.pdf">
<XIcon />
</AttachmentAction>
```
### Label the trigger
`AttachmentTrigger` covers the card with no text of its own, so give it an `aria-label` for what activating it does.
```tsx showLineNumbers
<AttachmentTrigger asChild>
<a
href={url}
target="_blank"
rel="noreferrer"
aria-label="Open workspace.png"
/>
</AttachmentTrigger>
```
The trigger sits behind the actions in the stacking order, so an `AttachmentAction` and the `AttachmentTrigger` never trap each other — both remain separately focusable and clickable.
### Keyboard scrolling
An `AttachmentGroup` scrolls horizontally. When its attachments are interactive: a trigger or actions, keyboard users reach off-screen items by tabbing to them. For a row of presentational attachments, make the group itself focusable and scrollable by adding `tabIndex={0}`, `role="group"`, and an `aria-label`.
### Meaning beyond color
The `error` state uses a destructive color. Keep the failure reason in `AttachmentDescription` so the state is not conveyed by color alone.
## API Reference
### Attachment
The root attachment container.
| Prop | Type | Default | Description |
| ------------- | ------------------------------------------------------------ | -------------- | ------------------------------------------------- |
| `state` | `"idle" \| "uploading" \| "processing" \| "error" \| "done"` | `"done"` | The upload state. Drives styling and the shimmer. |
| `size` | `"default" \| "sm" \| "xs"` | `"default"` | The attachment size. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Lay the media beside or above the content. |
| `className` | `string` | - | Additional classes to apply to the root element. |
### AttachmentMedia
The media slot for an icon or image preview.
| Prop | Type | Default | Description |
| ----------- | ------------------- | -------- | ---------------------------------------------- |
| `variant` | `"icon" \| "image"` | `"icon"` | Whether the media holds an icon or an `<img>`. |
| `className` | `string` | - | Additional classes to apply to the media slot. |
### AttachmentContent
Wraps the title and description.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the content slot. |
### AttachmentTitle
The attachment name. Shimmers while the attachment is `uploading` or `processing`.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ----------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the title. |
### AttachmentDescription
Secondary metadata such as the file type, size, or upload status.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ----------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the description. |
### AttachmentActions
A container for one or more actions, aligned to the end of the attachment.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the actions. |
### AttachmentAction
An action button. Renders a [`Button`](/docs/components/button) and accepts all of its props.
| Prop | Type | Default | Description |
| ---------- | ------------------------------------- | ----------- | ---------------------------------------- |
| `size` | `Button["size"]` | `"icon-xs"` | The button size. |
| `...props` | `React.ComponentProps<typeof Button>` | - | Props spread to the underlying `Button`. |
### AttachmentTrigger
A full-card overlay that activates the attachment. Renders a `<button>` by default.
| Prop | Type | Default | Description |
| ---------- | -------------------------------- | ------- | -------------------------------------------- |
| `asChild` | `boolean` | `false` | Render as the child element, such as a link. |
| `...props` | `React.ComponentProps<"button">` | - | Props spread to the trigger element. |
### AttachmentGroup
Lays out attachments in a horizontally scrollable, snapping row.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ----------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the group. |

View File

@@ -0,0 +1,304 @@
---
title: Bubble
description: Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content.
base: radix
component: true
---
<ComponentPreview
styleName="radix-rhea"
name="bubble-demo"
previewClassName="h-auto theme-blue"
/>
The `Bubble` component displays framed conversational content. Use it for chat text, short structured output, quoted replies, suggestions, and reactions.
For full-featured chat interfaces, use the [`Message`](/docs/components/message) component. `Bubble` is intentionally scoped to the bubble surface. Place avatars, names, timestamps, metadata, and message-level actions in [`Message`](/docs/components/message).
## Installation
<CodeTabs>
<TabsList>
<TabsTrigger value="cli">Command</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">
```bash
npx shadcn@latest add bubble
```
</TabsContent>
<TabsContent value="manual">
<Steps className="mb-0 pt-2">
<Step>Copy and paste the following code into your project.</Step>
<ComponentSource
name="bubble"
title="components/ui/bubble.tsx"
styleName="radix-rhea"
/>
<Step>Update the import paths to match your project setup.</Step>
</Steps>
</TabsContent>
</CodeTabs>
## Usage
```tsx showLineNumbers
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
```
```tsx showLineNumbers
<Bubble>
<BubbleContent>
I checked the registry output and removed the stale route.
</BubbleContent>
<BubbleReactions>
<span>👍</span>
</BubbleReactions>
</Bubble>
```
## Composition
Use the following composition to build a bubble:
```text
Bubble
├── BubbleContent
└── BubbleReactions
```
Use `BubbleGroup` to group consecutive bubbles from the same sender:
```text
BubbleGroup
├── Bubble
│ └── BubbleContent
└── Bubble
└── BubbleContent
```
## Features
- Seven visual variants, from a strong primary bubble to unframed ghost content
- Start and end alignment for sender and receiver bubbles
- Reactions that anchor to the bubble edge with configurable side and alignment
- Bubbles size to their content, up to 80% of the container width
- Polymorphic content via `asChild` for link and button bubbles
- Customizable styling through the `className` prop on every part
## Examples
### Variants
Use `variant` to change the visual treatment of the bubble.
<ComponentPreview
styleName="radix-rhea"
name="bubble-variants"
previewClassName="h-auto theme-blue"
/>
| Variant | Description |
| ------------- | ------------------------------------------------------ |
| `default` | A strong primary bubble, usually for the current user. |
| `secondary` | The standard neutral bubble for conversation content. |
| `muted` | A lower-emphasis bubble for quiet supporting content. |
| `tinted` | A subtle primary-tinted bubble. |
| `outline` | A bordered bubble for secondary or rich content. |
| `ghost` | Unframed content for assistant text or rich content. |
| `destructive` | A destructive bubble for error or failed actions. |
A bubble sizes to its content, up to 80% of the container width. The `ghost` variant removes the max-width so assistant text and rich content can span the full row.
### Alignment
Use `align` on `Bubble` to align the bubble to the start or end of the conversation.
<ComponentPreview
styleName="radix-rhea"
name="bubble-alignment"
previewClassName="h-auto theme-blue"
/>
| align | Description |
| ------- | -------------------------------------------------- |
| `start` | Align the bubble to the start of the conversation. |
| `end` | Align the bubble to the end of the conversation. |
**Note:** When building chat interfaces, you probably want to use alignment on the `Message` component itself, not the `Bubble` component. You can use the `role` prop on the `Message` component to automatically align the bubble to the start or end of the conversation.
### Bubble Group
Use `BubbleGroup` to group consecutive bubbles from the same sender. Note the `align` prop should be set on the `Bubble` component itself, not the `BubbleGroup` component.
```text
BubbleGroup
├── Bubble
│ └── BubbleContent
└── Bubble
└── BubbleContent
```
<ComponentPreview
styleName="radix-rhea"
name="bubble-group-demo"
previewClassName="h-auto theme-blue"
/>
### Links and Buttons
You can turn a bubble into a link or button by using the `asChild` prop on `BubbleContent`.
<ComponentPreview
styleName="radix-rhea"
name="bubble-link-button"
previewClassName="h-auto theme-blue"
/>
```tsx showLineNumbers
import { Bubble, BubbleContent } from "@/components/ui/bubble"
export function BubbleLinkDemo() {
return (
<Bubble variant="muted">
<BubbleContent asChild>
<button>Click here</button>
</BubbleContent>
</Bubble>
)
}
```
### Reactions
Use `BubbleReactions` for bubble reactions. You can use it to display reactions or quick action buttons. Use `side` and `align` to position the row — `side="top"` anchors it to the upper edge. Reactions overlap the bubble edge, so leave vertical space between rows — the examples below use a larger `gap` for this reason.
<ComponentPreview
styleName="radix-rhea"
name="bubble-reactions"
previewClassName="h-auto theme-blue"
/>
### Show More / Collapsible
Long bubble content can be composed with [`Collapsible`](/docs/components/collapsible) to allow for a show more or show less interaction. Use the `CollapsibleTrigger` component to trigger the collapsible content.
<ComponentPreview
styleName="radix-rhea"
name="bubble-collapsible"
previewClassName="h-auto theme-blue"
/>
### Tooltip
Wrap a bubble in a [`Tooltip`](/docs/components/tooltip) to reveal metadata on hover, such as when a message was read.
<ComponentPreview
styleName="radix-rhea"
name="bubble-tooltip"
previewClassName="h-auto theme-blue"
/>
### Popover
Pair a bubble with a [`Popover`](/docs/components/popover) to surface more information on demand, such as the full error message for a failed action.
<ComponentPreview
styleName="radix-rhea"
name="bubble-popover"
previewClassName="h-auto theme-blue"
/>
## Accessibility
`Bubble` renders the presentational message surface. Keep conversation-level semantics on the surrounding container and follow the guidelines below.
### Labeling Reactions
Reactions render as a row of emoji. A screen reader reads each glyph with no context, and counters like `+8` are announced as "plus eight". Group the row as a single image with a descriptive `aria-label` so it announces once. `role="img"` also hides the individual emoji from assistive tech, so no `aria-hidden` is needed.
```tsx showLineNumbers
<BubbleReactions role="img" aria-label="Reactions: thumbs up, fire, and 8 more">
<span>👍</span>
<span>🔥</span>
<span>+8</span>
</BubbleReactions>
```
When reactions are interactive, render buttons instead and give icon-only buttons an `aria-label`.
```tsx showLineNumbers
<BubbleReactions>
<Button aria-label="Thumbs up" variant="secondary" size="icon-xs">
<ThumbsUpIcon />
</Button>
</BubbleReactions>
```
### Interactive Bubbles
When a bubble is clickable, render it as a real `<button>` or `<a>` with the `asChild` prop so it is focusable and exposes the correct role. `BubbleContent` ships a visible focus ring for interactive elements, and the accessible name comes from the bubble text. No extra label is needed.
```tsx showLineNumbers
<Bubble variant="muted" align="end">
<BubbleContent asChild>
<button type="button" onClick={onReply}>
I forgot my password
</button>
</BubbleContent>
</Bubble>
```
### Meaning Beyond Color
Bubble variants signal role and tone with color. Pair them with text, alignment, or icons so meaning is not conveyed by color alone. For a `destructive` bubble, keep the error context in the message text rather than relying on the color treatment.
## API Reference
### Bubble
The root bubble wrapper.
| Prop | Type | Default | Description |
| ----------- | ------------------------------------------------------------------------------------------ | ----------- | ------------------------------------------------ |
| `variant` | `"default" \| "secondary" \| "muted" \| "tinted" \| "outline" \| "ghost" \| "destructive"` | `"default"` | The bubble visual treatment. |
| `align` | `"start" \| "end"` | `"start"` | The inline alignment of the bubble. |
| `className` | `string` | - | Additional classes to apply to the root element. |
### BubbleContent
The bubble content wrapper.
| Prop | Type | Default | Description |
| ----------- | --------- | ------- | --------------------------------------------------- |
| `asChild` | `boolean` | `false` | Render the content as the child element. |
| `className` | `string` | - | Additional classes to apply to the content element. |
### BubbleReactions
Displays overlapped reactions for a bubble.
| Prop | Type | Default | Description |
| ----------- | ------------------- | ---------- | ------------------------------------------------ |
| `side` | `"top" \| "bottom"` | `"bottom"` | The side of the bubble to anchor the reactions. |
| `align` | `"start" \| "end"` | `"end"` | The inline alignment of the reactions. |
| `className` | `string` | - | Additional classes to apply to the reaction row. |
### BubbleGroup
Groups consecutive bubbles from the same sender.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ---------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the group root. |

View File

@@ -103,6 +103,24 @@ Use the `size="sm"` prop to set the size of the card to small. The small size va
previewClassName="h-96" previewClassName="h-96"
/> />
### Spacing
In addition to the `size` prop, you can use the `--card-spacing` CSS variable to control the spacing between sections and the inset of card parts.
<ComponentPreview
styleName="radix-nova"
name="card-spacing"
previewClassName="h-[34rem]"
/>
Use negative margins with `-mx-(--card-spacing)` to make content go edge to edge while keeping it aligned with the card inset. When the edge-to-edge content sits above a footer, use `-mb-(--card-spacing)` on `CardContent` to remove the section gap.
<ComponentPreview
styleName="radix-nova"
name="card-edge-to-edge"
previewClassName="h-[24rem]"
/>
### Image ### Image
Add an image before the card header to create a card with an image. Add an image before the card header to create a card with an image.
@@ -182,3 +200,70 @@ The `CardFooter` component is used for actions and secondary content at the bott
| Prop | Type | Default | | Prop | Type | Default |
| ----------- | -------- | ------- | | ----------- | -------- | ------- |
| `className` | `string` | - | | `className` | `string` | - |
## Changelog
### Spacing Variable
If you're upgrading from a previous version of the `Card` component, you'll need to apply the following updates to use the `--card-spacing` variable:
<Steps>
<Step>Update the Card root spacing classes.</Step>
Replace the hard-coded gap and vertical padding with `--card-spacing`, and set the default and small size values on the root:
```diff
className={cn(
- "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
```
<Step>Update CardHeader spacing classes.</Step>
Replace the horizontal padding and border spacing with the shared variable:
```diff
className={cn(
- "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
+ "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
```
<Step>Update CardContent and CardFooter spacing classes.</Step>
Use `--card-spacing` for the content inset and footer padding:
```diff
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
- className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
+ className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
```
```diff
className={cn(
- "flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
+ "flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
```
</Steps>
After applying these changes, you can customize card spacing by setting `--card-spacing` on the `Card` with an arbitrary property class:
```tsx
function Example() {
return <Card className="[--card-spacing:--spacing(6)]">...</Card>
}
```

View File

@@ -0,0 +1,277 @@
---
title: Marker
description: Displays an inline status, system note, bordered row, or labeled separator in a conversation.
base: radix
component: true
---
<ComponentPreview
styleName="radix-rhea"
name="marker-demo"
previewClassName="h-auto theme-blue"
/>
The `Marker` component displays inline conversation markers such as status updates, system notes, bordered rows, and labeled separators. Compose it with [`Message`](/docs/components/message) in a conversation thread.
## Installation
<CodeTabs>
<TabsList>
<TabsTrigger value="cli">Command</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">
```bash
npx shadcn@latest add marker
```
</TabsContent>
<TabsContent value="manual">
<Steps className="mb-0 pt-2">
<Step>Copy and paste the following code into your project.</Step>
<ComponentSource
name="marker"
title="components/ui/marker.tsx"
styleName="radix-rhea"
/>
<Step>Update the import paths to match your project setup.</Step>
</Steps>
</TabsContent>
</CodeTabs>
## Usage
```tsx showLineNumbers
import { Marker, MarkerContent, MarkerIcon } from "@/components/ui/marker"
```
```tsx showLineNumbers
<Marker>
<MarkerIcon>
<CheckIcon />
</MarkerIcon>
<MarkerContent>Explored 4 files</MarkerContent>
</Marker>
```
## Composition
Use the following composition to build a marker:
```text
Marker
├── MarkerIcon
└── MarkerContent
```
## Features
- Inline marker, bordered row, and labeled separator variants
- Decorative icon slot that is hidden from assistive tech
- Polymorphic root via `asChild` for link and button markers
- Pairs with the [`shimmer`](/docs/utils/shimmer) utility for streaming status text
- Customizable styling through the `className` prop on every part
## Examples
### Variants
Use `variant` to switch between an inline marker, bordered row, and labeled separator.
<ComponentPreview
styleName="radix-rhea"
name="marker-variants"
previewClassName="h-auto theme-blue"
/>
| Variant | Description |
| ----------- | ---------------------------------------------------- |
| `default` | An inline marker for status, notes, and actions. |
| `border` | A default marker with a bottom border under the row. |
| `separator` | A centered label with divider lines on each side. |
### Status
Set `role="status"` and include a [`Spinner`](/docs/components/spinner) for streaming or in-progress markers so updates are announced.
<ComponentPreview
styleName="radix-rhea"
name="marker-status"
previewClassName="h-auto theme-blue"
/>
### Shimmer
Add the [`shimmer`](/docs/utils/shimmer) utility class to `MarkerContent` for an animated streaming-text effect. The utility ships with the `shadcn` package — see the shimmer docs for installation.
<ComponentPreview
styleName="radix-rhea"
name="marker-shimmer"
previewClassName="h-auto theme-blue"
/>
### Separator
Use the `separator` variant for labeled dividers, such as dates or section breaks, in a conversation.
<ComponentPreview
styleName="radix-rhea"
name="marker-separator"
previewClassName="h-auto theme-blue"
/>
### Border
Use the `border` variant for status rows that should keep the default marker alignment while separating the next row.
<ComponentPreview
styleName="radix-rhea"
name="marker-border"
previewClassName="h-auto theme-blue"
/>
### With Icon
Use `MarkerIcon` to render an icon alongside the content. Use `flex-col` to stack the icon above the content.
<ComponentPreview
styleName="radix-rhea"
name="marker-icon"
previewClassName="h-auto theme-blue"
/>
### Links and Buttons
Turn a marker into a link or button with the `asChild` prop on `Marker`.
<ComponentPreview
styleName="radix-rhea"
name="marker-link-button"
previewClassName="h-auto theme-blue"
/>
```tsx showLineNumbers
import { Marker, MarkerContent } from "@/components/ui/marker"
export function MarkerLinkDemo() {
return (
<Marker asChild>
<a href="#">
<MarkerContent>View the pull request</MarkerContent>
</a>
</Marker>
)
}
```
## Accessibility
`Marker` is presentational by default. The correct semantics depend on how you use it, so choose the role based on intent rather than relying on a single default.
### Status and Progress
For streaming or progress markers such as "Thinking..." or a running tool, set `role="status"` so assistive tech announces the update as it appears. `Marker` forwards `role` to the underlying element.
```tsx showLineNumbers
<Marker role="status">
<MarkerIcon>
<Spinner />
</MarkerIcon>
<MarkerContent>Compacting conversation</MarkerContent>
</Marker>
```
### Labeled Separators
A separator that carries text, such as a date or a section label, needs no role. The divider lines are decorative CSS pseudo-elements, and the text is announced as ordinary content.
```tsx showLineNumbers
<Marker variant="separator">
<MarkerContent>Today</MarkerContent>
</Marker>
```
<Callout>
**Note:** Do not add `role="separator"` to a labeled divider. A separator
takes its accessible name from `aria-label`, not from its text, and its
contents are treated as presentational, so the visible label would not be
announced. Reserve `role="separator"` for a divider with no meaningful text.
</Callout>
### Bordered Markers
A bordered marker keeps the same semantics as the default marker. The bottom border is decorative, so choose `role="status"`, `asChild`, or no role based on the marker's purpose.
```tsx showLineNumbers
<Marker variant="border">
<MarkerIcon>
<FileTextIcon />
</MarkerIcon>
<MarkerContent>Opened implementation notes</MarkerContent>
</Marker>
```
### Decorative Icons
`MarkerIcon` is decorative and hidden from assistive tech with `aria-hidden`, so the adjacent `MarkerContent` carries the meaning. For an icon-only marker, provide an `aria-label` or visible text so it is not announced as empty.
```tsx showLineNumbers
<Marker aria-label="Synced">
<MarkerIcon>
<CheckIcon />
</MarkerIcon>
</Marker>
```
### Interactive Markers
When a marker links or triggers an action, render it as a real `<button>` or `<a>` with the `asChild` prop so it is focusable and exposes the correct role. The accessible name comes from the marker text.
```tsx showLineNumbers
<Marker asChild>
<a href="/files">
<MarkerIcon>
<FileTextIcon />
</MarkerIcon>
<MarkerContent>Explored 4 files</MarkerContent>
</a>
</Marker>
```
## API Reference
### Marker
The root marker element. The file also exports `markerVariants` for composing the marker styles into custom components.
| Prop | Type | Default | Description |
| ----------- | -------------------------------------- | ----------- | ------------------------------------------------ |
| `variant` | `"default" \| "border" \| "separator"` | `"default"` | The marker layout. |
| `asChild` | `boolean` | `false` | Render as the child element, such as a link. |
| `className` | `string` | - | Additional classes to apply to the root element. |
### MarkerIcon
A decorative icon slot. Hidden from assistive tech with `aria-hidden`.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | --------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the icon slot. |
### MarkerContent
The marker text content.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the content slot. |

View File

@@ -0,0 +1,586 @@
---
title: Message Scroller
description: A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.
base: radix
component: true
---
<ComponentPreview
styleName="radix-rhea"
name="message-scroller-demo"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
## What Makes a Great Streaming Chat Experience
Building a chat interface used to be simple. You create an inverted list with
an input. Type a message, it appends at the bottom. When a reply comes in, the
list grows and scrolls. Done.
Streaming breaks that model. Messages arrive in chunks while you may still be
reading, scrolling, or looking somewhere else entirely.
Now the challenge is preserving the reader's place while the conversation keeps
changing. Get that wrong and the experience feels jumpy: people are pulled to
the bottom, lose context, and have to find their way back.
In practice, this comes down to scroll: when to follow, when to hold, and when
to let the reader decide. A great streaming chat should:
1. **Move only when the reader asked to move.** If someone is reading, dont pull them somewhere else. Auto-scroll should never be the default.
2. **Follow only while theyre following.** If theyre at the live edge, keep the stream in view. If they scroll away, leave them there.
3. **Every interaction is a signal.** Scrolling is not the only one. Selecting text, using the keyboard, opening a link, or searching should all stop the interface from moving.
4. **Start a new turn near the top of the viewport.** This gives the new turn somewhere it can be read from the beginning.
5. **Then stream in the answer.** The answer should grow into the screen, not immediately push everything away.
6. **Keep part of the previous conversation in context.** The prompt and reply should stay visually connected, and enough of the previous turn should remain visible so the reader knows where they are.
7. **Let new content arrive offscreen.** The conversation can keep streaming without changing what the reader is looking at.
8. **Show whats happening out of view.** Make it clear when a response is still streaming or when new messages have arrived.
9. **Make it easy to return to the latest reply.** A “Jump to latest” action should bring the reader back and resume following.
10. **Let people jump anywhere in the conversation.** Long threads need message links, search, unread markers, and direct navigation.
11. **Reopen where the reader left off.** A saved conversation should open at the last meaningful turn. Often this is the last user message. Not the absolute bottom.
12. **Keep the readers place when layout changes.** Images load. Markdown expands. Code blocks render. Older messages appear above. None of that should make the reader lose their place.
13. **Handle interruptions without stealing position.** Stopping, retrying, regenerating, branching, or errors should not unexpectedly move the conversation.
14. **Stay responsive in long threads.** Streaming text, markdown, code, images, and long history should still feel responsive.
15. **Be accessible without the noise.** Keep the transcript navigable, preserve keyboard focus, and announce important events at a comfortable pace.
**Never move the reader against their intent.**
## MessageScroller
MessageScroller is a chat transcript scroller built for these behaviors.
`MessageScrollerProvider` owns the scroll state and transcript-row behavior:
opening position, streamed output, new-turn anchoring, prepended history,
visibility, and scroll controls. `MessageScroller` is the styled frame that
renders inside it.
MessageScroller is scoped to the scroll viewport. It does not own messages, AI state,
transport, persistence, branching, or model state. Your product code stays
focused on composing messages, markers, tools, attachments, and prompt inputs.
It gives you the scroll behavior that chat needs, without taking over the rest
of the chat UI. And it stays fast, even in long conversations with rich
markdown.
## Installation
<CodeTabs>
<TabsList>
<TabsTrigger value="cli">Command</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">
```bash
npx shadcn@latest add message-scroller
```
</TabsContent>
<TabsContent value="manual">
<Steps className="mb-0 pt-2">
<Step>Install the following dependencies:</Step>
```bash
npm install @shadcn/react
```
<Step>Copy and paste the following code into your project.</Step>
<ComponentSource
name="message-scroller"
title="components/ui/message-scroller.tsx"
styleName="radix-nova"
/>
<Step>Update the import paths to match your project setup.</Step>
</Steps>
</TabsContent>
</CodeTabs>
## Usage
```tsx
import { Message } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
```
```tsx
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
<Message />
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
```
`MessageScroller` fills its parent, so place it inside a height-constrained
container.
```tsx
<div className="flex h-screen flex-col">
<MessageScrollerProvider>
<MessageScroller className="flex-1">{/* transcript */}</MessageScroller>
</MessageScrollerProvider>
</div>
```
## Composition
```tsx
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
<MessageScrollerItem>
{/* a message, marker, or row */}
</MessageScrollerItem>
<MessageScrollerItem />
<MessageScrollerItem />
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
```
- **`MessageScrollerProvider`** — the headless root. Owns scroll state and the
behavior props for opening position, auto-scroll, anchoring, scroll commands,
and visibility tracking.
- **`MessageScroller`** — the styled frame. Lays out the viewport, content, and
controls inside the provider.
- **`MessageScrollerViewport`** — the scrollable element. Receives native scroll
events and preserves the visible row when older messages are prepended.
- **`MessageScrollerContent`** — the transcript container. Holds the rows and
provides the live-region defaults for new messages.
- **`MessageScrollerItem`** — the transcript row boundary. Wrap every direct
child of the content so the scroller can measure, anchor, preserve position,
track visibility, and jump to it. An item can be a message, marker, typing
indicator, separator, join/leave event, or "load earlier" row.
- **`MessageScrollerButton`** — the scroll control. Scrolls to the start or end of the transcript and is inert until there is content in its direction.
## Core Concepts
### Anchoring Turns
A turn is the part of the conversation that starts a new exchange. In a simple
AI chat, that is usually the user's message and the assistant reply that follows.
An anchor is the row the viewport should treat as the start of that turn. Mark
that row with `scrollAnchor`. When a new anchor is appended, the viewport moves
it near the top and keeps a peek of the previous item above it, so the new turn
does not feel detached from its context.
```tsx
// This tells the scroller to anchor the user's message for the next turn.
<MessageScrollerItem
messageId={message.id}
scrollAnchor={message.role === "user"}
/>
```
Scroll anchors are not tied to message role. You can turn any row into an anchor:
a user message, a system marker, a handoff event, or anything else that starts a
meaningful turn. `MessageScroller` only needs to know which row should anchor the
viewport.
In the following example, the user's message is anchored. When you send a new message, the viewport anchors it near the top and appends the assistant reply below it. Toggle the anchor to the assistant's message to see the difference.
<ComponentPreview
styleName="radix-rhea"
name="message-scroller-anchoring"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
### Group Chat
In a group chat, the turn boundary is more specific than "the user message". It is often
the message that asks the model to respond, or a marker like "Marcus joined the
chat". Typing indicators and history controls usually should not anchor.
Because anchoring is role-independent, you can anchor a marker just as easily as
a message.
```tsx
<MessageScrollerItem messageId="marcus-joined" scrollAnchor>
<Marker variant="separator">
<MarkerContent>Marcus joined the chat</MarkerContent>
</Marker>
</MessageScrollerItem>
```
<ComponentPreview
styleName="radix-rhea"
name="message-scroller-group-chat"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
### Keeping Context Visible
When a new turn starts, it should still feel like part of the same continuous
thread. `scrollPreviousItemPeek` keeps a slice of the previous item visible
above the anchor, so the reader keeps their context instead of feeling like the
conversation restarted on a blank page.
```tsx
// Keep 64px of the previous turn visible above the newly anchored row.
<MessageScrollerProvider scrollPreviousItemPeek={64}>
<MessageScroller>{/* anchored turns */}</MessageScroller>
</MessageScrollerProvider>
```
Adjust the peek amount in the example below to see how it affects the conversation.
<ComponentPreview
styleName="radix-rhea"
name="message-scroller-previous-context"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
### Following the Live Edge
When the reader is at the live edge, either because they stayed there or
returned there, `autoScroll` keeps streamed replies in view as they grow.
Scrolling away from the live edge releases the view, whether by wheel, touch,
keyboard scroll keys, or dragging the scrollbar. An explicit message jump
releases it too. New chunks can then arrive without moving the reader.
```tsx
<MessageScrollerProvider autoScroll>
<MessageScroller>{/* streamed turns */}</MessageScroller>
</MessageScrollerProvider>
```
<ComponentPreview
styleName="radix-rhea"
name="message-scroller-streaming"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
Calling `scrollToEnd`, or pressing `MessageScrollerButton`, re-engages
follow-output when `autoScroll` is enabled, so a reader who scrolled away can
return to the live edge and keep following. The root and viewport expose
`data-autoscrolling` while that programmatic scroll to the latest message runs,
so you can conditionally apply styles during the transition.
### Opening Saved Threads
It can seem reasonable to reopen a saved thread at the absolute end of the
transcript, but that often drops the reader into the conversation without enough
context. A better default is `"last-anchor"`: show the last meaningful turn,
like the user's latest message, with the reply below it.
That gives the reader an immediate place in the thread. They can see what they
asked, where the answer starts, and continue from there without reconstructing
the conversation from the bottom edge.
```tsx
<MessageScrollerProvider defaultScrollPosition="last-anchor">
<MessageScroller>{/* transcript */}</MessageScroller>
</MessageScrollerProvider>
```
<ComponentPreview
styleName="radix-rhea"
name="message-scroller-opening-position"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
hideCode
/>
`"last-anchor"` is keyed on `scrollAnchor`, not message role. If no anchor
exists, or the last anchored turn already fits in the viewport, it falls back to
`"end"`.
Use `"start"` when you want to resume at the beginning of a conversation, or
`"end"` when the absolute latest message is the right place to land.
### Loading Earlier Messages
Loading earlier messages should not move the conversation the reader is already
looking at. When older rows are prepended above the current transcript,
`MessageScrollerViewport` preserves the visible row so the reader stays in the
same place while history loads above them.
This is enabled by default through `preserveScrollOnPrepend`.
<ComponentPreview
styleName="radix-rhea"
name="message-scroller-load-history"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
Use stable `messageId` values for message rows. That gives the scroller a
specific row to preserve instead of guessing from whichever pixel happens to sit
at the viewport edge.
### Animating New Messages
`MessageScrollerItem` can be animated directly. Create a motion version of the
item, keep `messageId` and `scrollAnchor` on it, and use transform and opacity
for the entrance.
A common chat pattern is to animate the user's message when it is sent, then let
the assistant reply stream into a regular row below it. Start the user row below
its final position so it feels like it rises from the live edge of the viewport.
```tsx
const MotionMessageScrollerItem = motion.create(MessageScrollerItem)
```
<ComponentPreview
styleName="radix-rhea"
name="message-scroller-animation"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
Avoid animating height, margin, or padding for row entrances; those changes can
fight the scroller's positioning work. If the reader prefers reduced motion,
skip the entrance animation and keep the scroll behavior the same.
### Jumping to Messages
Search results, permalinks, outline items, and toolbar buttons often need to
drive the transcript from outside the message list. Use `useMessageScroller` for
those controls. Because the hooks read from `MessageScrollerProvider`, they work
in any component inside the provider, including controls rendered outside the
`MessageScroller` frame.
```tsx
import { useMessageScroller } from "@/components/ui/message-scroller"
```
```tsx
const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller()
```
<ComponentPreview
styleName="radix-rhea"
name="message-scroller-commands"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
hideCode
/>
`scrollToMessage` targets the `messageId` on `MessageScrollerItem`, so rows that
need to be addressable should have stable ids. `scrollToMessage` returns `false`
when the target is not mounted and cannot be queued.
`scrollToMessage` can queue a target before items exist, which covers
client-resolved permalinks while the transcript mounts. After rows have mounted,
a missing id returns `false` instead of starting a guessed retry loop. A `true`
result means the scroll ran or was queued, not that the row is already in view.
### Tracking the Reader's Position
Use `useMessageScrollerVisibility` to track the reader's position in the
conversation. A common example is a table-of-contents or a jump menu that
highlights the current anchored turn.
```tsx
import { useMessageScrollerVisibility } from "@/components/ui/message-scroller"
```
```tsx
const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()
```
<ComponentPreview
styleName="radix-rhea"
name="message-scroller-visibility"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
hideCode
/>
`currentAnchorId` answers "where am I" by reporting the current anchored turn,
and it stays set after that anchor scrolls above the viewport. `visibleMessageIds`
answers "what is on screen", in document order.
Visibility is pay-for-what-you-use. Tracking only runs while something
subscribes to `useMessageScrollerVisibility`, and rows need a `messageId` to
participate.
### Reading Scroll State
Use `useMessageScrollerScrollable` when you need scroll state in JavaScript, such
as a status indicator or a custom "jump to latest" control. It reports which
edges the viewport can still scroll toward; "at the start/end" is the negation
(`!start` / `!end`), and "scrollable at all" is `start || end`. For styling the
scroller itself, prefer the `data-scrollable` attribute.
```tsx
import { useMessageScrollerScrollable } from "@/components/ui/message-scroller"
```
```tsx
const { start, end } = useMessageScrollerScrollable()
```
<ComponentPreview
styleName="radix-rhea"
name="message-scroller-scrollable"
className="rounded-[34px] sm:rounded-4xl"
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
/>
## Performance
`MessageScroller` is benchmarked against large transcripts with markdown and
composed message rows.
Our performance goal for `MessageScroller` is to keep the scroll hot path outside of React state: no React rerenders for
transcript rows, no forced layout on every scroll, and as little off-screen paint
work as the browser can avoid.
Scroll position, anchoring, and follow-output are tracked imperatively and mirrored onto the root and viewport through `data-*` attributes, so scrolling and streaming do not rerender transcript rows.
The styled `MessageScrollerItem` also ships with `content-visibility: auto` and
`contain-intrinsic-size`. Rows stay in the DOM for selection, copy,
find-in-page, SSR, and assistive tech, but the browser can skip rendering work
for rows far outside the viewport.
Visibility tracking is pay-for-what-you-use. A jump menu or active
turn indicator costs nothing until something subscribes to
`useMessageScrollerVisibility`.
This is comfortable for the expected range of a chat transcript: hundreds to low
thousands of turns, including messages with markdown and composed components.
## Virtualization
Virtualization is intentionally left outside the primitive. `MessageScroller`
renders real DOM rows and stays fast well into the thousands of turns (see
[Performance](#performance)), so most transcripts never need it.
When a transcript is large enough to need virtualization, use
`MessageScrollerViewport` as the scroll element and let the virtualizer own the
rows.
```tsx showLineNumbers
import * as React from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
function VirtualizedTranscript({
messages,
}: {
messages: Array<{ id: string; content: React.ReactNode }>
}) {
const viewportRef = React.useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => viewportRef.current,
estimateSize: () => 86,
getItemKey: (index) => messages[index]?.id ?? index,
overscan: 8,
})
return (
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport ref={viewportRef}>
<MessageScrollerContent className="block min-h-full">
<div
className="relative w-full"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const message = messages[virtualItem.index]
if (!message) {
return null
}
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className="absolute start-0 top-0 w-full"
style={{
transform: `translateY(${virtualItem.start}px)`,
}}
>
<Message>{message.content}</Message>
</div>
)
})}
</div>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
)
}
```
## Accessibility
`MessageScroller` keeps the scroll container keyboard reachable and the
transcript announceable without forcing a specific message UI.
`MessageScrollerViewport` is a labelled, keyboard-focusable scroll region by
default. It uses `role="region"`, `aria-label="Messages"`, and `tabIndex={0}`,
so keyboard users can focus the transcript and scroll it directly.
`MessageScrollerContent` marks the transcript as a live region with
`role="log"` and `aria-relevant="additions"`. New rows can be announced, but
streamed text mutations do not have to be announced token by token.
```tsx
<MessageScrollerContent aria-busy={status === "streaming"}>
{/* messages */}
</MessageScrollerContent>
```
Pass `aria-busy` while a turn streams if announcements should wait for the
completed message row.
`MessageScrollerButton` renders a real button. When there is nothing to scroll
toward, it sets `inert`, uses `tabIndex={-1}`, and exposes `data-active="false"`
so inactive scroll controls do not create extra focus stops.
## Unstyled
The behavior in `MessageScroller` comes from the `@shadcn/react` package. To use
it directly with your own markup and styles, see
[Message Scroller](/docs/react/message-scroller) under @shadcn/react.
## API Reference
The props, data attributes, and hooks for every part are documented on the
[@shadcn/react Message Scroller](/docs/react/message-scroller#api-reference) page.
They are identical for the styled component and the unstyled parts.

View File

@@ -0,0 +1,248 @@
---
title: Message
description: Displays a message in a conversation, with optional avatar, header, footer, and alignment.
base: radix
component: true
---
<ComponentPreview
styleName="radix-rhea"
name="message-demo"
previewClassName="h-auto theme-blue"
/>
The `Message` component lays out a single message in a conversation. It handles the avatar, alignment, header, and footer around the message surface.
For AI apps, you can render reasoning steps, tool calls and assistant messages using the `Message` component.
## Installation
<CodeTabs>
<TabsList>
<TabsTrigger value="cli">Command</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">
```bash
npx shadcn@latest add message
```
</TabsContent>
<TabsContent value="manual">
<Steps className="mb-0 pt-2">
<Step>Copy and paste the following code into your project.</Step>
<ComponentSource
name="message"
title="components/ui/message.tsx"
styleName="radix-rhea"
/>
<Step>Update the import paths to match your project setup.</Step>
</Steps>
</TabsContent>
</CodeTabs>
## Usage
```tsx showLineNumbers
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Message, MessageAvatar, MessageContent } from "@/components/ui/message"
```
```tsx showLineNumbers
<Message>
<MessageAvatar>
<Avatar>
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<Bubble>
<BubbleContent>How can I help you today?</BubbleContent>
</Bubble>
</MessageContent>
</Message>
```
**Note:** `Message` owns the row layout—avatar, alignment, header, and footer.
Render the visible message surface inside it with
[`Bubble`](/docs/components/bubble). For the scroll container around a
conversation, use [`MessageScroller`](/docs/components/message-scroller).
## Composition
Use the following composition to build a message:
```text
Message
├── MessageAvatar
└── MessageContent
├── MessageHeader
├── Bubble
└── MessageFooter
```
Use `MessageGroup` to stack consecutive messages from the same sender:
```text
MessageGroup
├── Message
└── Message
```
## Features
- Start and end alignment for sender and receiver rows via the `align` prop
- Avatar slot that anchors to the bottom of the message and stays clear of the footer
- Header and footer slots for sender names, status, and message actions
- Footer follows the message side; actions stay aligned on `align="end"` rows
- Group wrapper for stacking consecutive messages from the same sender
- Customizable styling through the `className` prop on every part
## Examples
### Avatar
Use `MessageAvatar` to render an avatar next to the message. Set `align="end"` on the message to align the avatar to the end of the message.
<ComponentPreview
styleName="radix-rhea"
name="message-avatar"
previewClassName="h-auto theme-blue"
/>
| align | Description |
| ------- | --------------------------------------------------- |
| `start` | Align the message to the start of the conversation. |
| `end` | Align the message to the end of the conversation. |
### Group
Use `MessageGroup` to stack consecutive messages from the same sender. Render an empty `MessageAvatar` on the earlier messages to keep them aligned with the avatar on the last one.
<ComponentPreview
styleName="radix-rhea"
name="message-group"
previewClassName="h-auto theme-blue"
/>
### Header and Footer
Use `MessageHeader` for a sender name and `MessageFooter` for metadata such as a delivery or read status.
<ComponentPreview
styleName="radix-rhea"
name="message-header-footer"
previewClassName="h-auto theme-blue"
/>
### Actions
Place message-level actions in `MessageFooter`, such as copy, retry, or feedback buttons.
<ComponentPreview
styleName="radix-rhea"
name="message-actions"
previewClassName="h-auto theme-blue"
/>
### Attachment
<ComponentPreview
styleName="radix-rhea"
name="message-attachment"
previewClassName="h-auto theme-blue"
/>
## Accessibility
`Message` is a presentational layout wrapper. Accessibility comes from the content you place inside it.
### Label icon-only actions
Action buttons in `MessageFooter` are usually icon-only, so give each one an `aria-label`.
```tsx showLineNumbers
<MessageFooter>
<Button variant="ghost" size="icon" aria-label="Copy">
<CopyIcon />
</Button>
</MessageFooter>
```
### Status updates
For in-progress messages, use a [`Marker`](/docs/components/marker) with `role="status"` so assistive tech announces the update as it appears.
```tsx showLineNumbers
<Message>
<Marker role="status">
<MarkerIcon>
<Spinner />
</MarkerIcon>
<MarkerContent>Checking the logs...</MarkerContent>
</Marker>
</Message>
```
## API Reference
### Message
The message row wrapper.
| Prop | Type | Default | Description |
| ----------- | ------------------ | --------- | ------------------------------------------------- |
| `align` | `"start" \| "end"` | `"start"` | The alignment of the message in the conversation. |
| `className` | `string` | - | Additional classes to apply to the row. |
### MessageGroup
Groups consecutive messages from the same sender.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ---------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the group root. |
### MessageAvatar
The avatar slot, aligned to the bottom of the message. When the message has a `MessageFooter`, the avatar shifts up to stay aligned with the message surface instead of the footer.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ----------------------------------------------- |
| `className` | `string` | - | Additional classes to apply to the avatar slot. |
### MessageContent
Wraps the header, message surface, and footer.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the content slot. |
### MessageHeader
Displays content above the message, such as a sender name. Stays aligned to the start regardless of `align`.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the header. |
### MessageFooter
Displays content below the message, such as status or actions. Aligns to the message side.
| Prop | Type | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------ |
| `className` | `string` | - | Additional classes to apply to the footer. |

View File

@@ -5,9 +5,11 @@
"alert", "alert",
"alert-dialog", "alert-dialog",
"aspect-ratio", "aspect-ratio",
"attachment",
"avatar", "avatar",
"badge", "badge",
"breadcrumb", "breadcrumb",
"bubble",
"button", "button",
"button-group", "button-group",
"calendar", "calendar",
@@ -35,7 +37,10 @@
"item", "item",
"kbd", "kbd",
"label", "label",
"marker",
"menubar", "menubar",
"message",
"message-scroller",
"native-select", "native-select",
"navigation-menu", "navigation-menu",
"pagination", "pagination",

View File

@@ -4,10 +4,12 @@
"components", "components",
"(root)", "(root)",
"changelog", "changelog",
"react",
"forms", "forms",
"installation", "installation",
"dark-mode", "dark-mode",
"rtl", "rtl",
"utils",
"registry" "registry"
] ]
} }

View File

@@ -0,0 +1,277 @@
---
title: Message Scroller
description: Use the MessageScroller behavior directly from the @shadcn/react package with your own markup and styles.
---
`MessageScroller` ships as a headless primitive in the `@shadcn/react` package.
The package owns all of the scroll behavior, anchoring turns, following streamed
output, preserving the reader's place as history loads, and tracking visibility,
and renders no styles of its own.
The `message-scroller.tsx` component in the registry is a thin wrapper that adds
Tailwind classes on top. Use the package directly when you want full control over
the markup and styles, or when you are not using the registry.
For the behavior guide and live examples, see the
[Message Scroller](/docs/components/radix/message-scroller) component.
## Installation
```bash
npm install @shadcn/react
```
## Usage
```tsx
import {
MessageScroller,
useMessageScroller,
} from "@shadcn/react/message-scroller"
```
The package exports a namespace object instead of flat components. The parts and
behavior are the same as the styled component, just unstyled.
```tsx
<MessageScroller.Provider>
<MessageScroller.Root>
<MessageScroller.Viewport>
<MessageScroller.Content>
{messages.map((message) => (
<MessageScroller.Item
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
{/* your message UI */}
</MessageScroller.Item>
))}
</MessageScroller.Content>
</MessageScroller.Viewport>
<MessageScroller.Button />
</MessageScroller.Root>
</MessageScroller.Provider>
```
## Parts
If you are coming from the styled component, the flat parts map to the namespace
object like this.
| Styled component | Unstyled part |
| ------------------------- | -------------------------- |
| `MessageScrollerProvider` | `MessageScroller.Provider` |
| `MessageScroller` | `MessageScroller.Root` |
| `MessageScrollerViewport` | `MessageScroller.Viewport` |
| `MessageScrollerContent` | `MessageScroller.Content` |
| `MessageScrollerItem` | `MessageScroller.Item` |
| `MessageScrollerButton` | `MessageScroller.Button` |
The hooks are imported the same way and behave identically, since they read from
`MessageScroller.Provider`.
```tsx
import {
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"
```
## Example
Here is a complete example that brings its own styles and wires the scroller to
the AI SDK.
```tsx showLineNumbers
"use client"
import { useChat } from "@ai-sdk/react"
import { MessageScroller } from "@shadcn/react/message-scroller"
import { DefaultChatTransport } from "ai"
import { ChatInput } from "@/components/chat-input"
export function Chat() {
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
})
return (
<div className="flex h-svh w-full flex-col">
<MessageScroller.Provider>
<MessageScroller.Root className="relative flex flex-1 flex-col overflow-hidden">
<MessageScroller.Viewport className="flex flex-1 flex-col overflow-y-auto">
<MessageScroller.Content className="flex flex-col gap-4 p-6 text-base">
{messages.map((message, index) => (
<MessageScroller.Item
key={message.id}
messageId={`message-${index}`}
scrollAnchor={message.role === "user"}
>
<div className="rounded-lg bg-muted p-4">
{message.parts.map((part, i) =>
part.type === "text" ? (
<span key={i}>{part.text}</span>
) : null
)}
</div>
</MessageScroller.Item>
))}
</MessageScroller.Content>
</MessageScroller.Viewport>
<MessageScroller.Button className="absolute bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-background px-3 py-1 text-sm font-medium inert:opacity-0">
Jump to latest
</MessageScroller.Button>
</MessageScroller.Root>
</MessageScroller.Provider>
<ChatInput onSend={sendMessage} disabled={status !== "ready"} />
</div>
)
}
```
## API Reference
### MessageScroller.Provider
The headless root. It owns scroll state and the behavior props, and provides
them to the parts and the hooks. It renders no DOM of its own.
| Prop | Type | Default | Description |
| ------------------------ | ----------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `autoScroll` | `boolean` | `false` | Follow new content only while the reader is already at the live edge. Wheel, touch, keyboard scroll, and explicit jumps release it. |
| `defaultScrollPosition` | `"start" \| "end" \| "last-anchor"` | `"end"` | Opening position on the first non-empty render, applied once. `"last-anchor"` opens at the last `scrollAnchor` row and falls back to `"end"` when the turn fits or no anchor exists. |
| `scrollEdgeThreshold` | `number` | `8` | Distance from either edge that still counts as being at the start or end. Controls state attributes and scroll button visibility. |
| `scrollMargin` | `number` | `0` | Margin applied to the aligned edge for `scrollToMessage`, visibility, and programmatic targets. |
| `scrollPreviousItemPeek` | `number` | `64` | Extra margin added to `scrollMargin` when a newly appended `scrollAnchor` item is positioned so part of the previous item stays visible. |
### MessageScroller.Root
The frame and layout container. It fills its parent, so use it inside a
height-constrained layout, within a `MessageScroller.Provider`.
| Prop | Type | Default | Description |
| ---------- | ----------------------------- | ------- | ---------------------------------- |
| `...props` | `React.ComponentProps<"div">` | - | Props spread to the frame element. |
The root mirrors the scroll-state attributes below (the viewport carries them
too), so you can style the container by scroll state, such as edge fades on the
frame.
| Data attribute | Value | Description |
| -------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `data-scrollable` | `"start"` \| `"end"` \| `"start end"` \| _absent_ | Edges the viewport can scroll toward. Query one with `[data-scrollable~="end"]`; absent means it fits. |
| `data-autoscrolling` | present | Present while the viewport is programmatically scrolling to the latest message. |
### MessageScroller.Viewport
The scrollable viewport.
| Prop | Type | Default | Description |
| ------------------------- | ----------------------------- | ------------ | ------------------------------------------------------------------------- |
| `preserveScrollOnPrepend` | `boolean` | `true` | Keep the first visible message item stable when older rows are prepended. |
| `role` | `string` | `"region"` | Landmark role for the labelled scrollable transcript viewport. |
| `aria-label` | `string` | `"Messages"` | Accessible name for the scrollable chat transcript. |
| `tabIndex` | `number` | `0` | Makes the transcript viewport keyboard-scrollable. |
| `...props` | `React.ComponentProps<"div">` | - | Props spread to the viewport element. |
| Data attribute | Value | Description |
| -------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `data-scrollable` | `"start"` \| `"end"` \| `"start end"` \| _absent_ | Edges the viewport can scroll toward. Query one with `[data-scrollable~="end"]`; absent means it fits. |
| `data-autoscrolling` | present | Present while the viewport is programmatically scrolling to the latest message. |
### MessageScroller.Content
The transcript content element. Every direct child should be a
`MessageScroller.Item`.
| Prop | Type | Default | Description |
| ----------------- | ----------------------------- | ------------- | ----------------------------------------------------------------------- |
| `role` | `string` | `"log"` | ARIA role applied to the message list for live announcements. |
| `aria-relevant` | `string` | `"additions"` | Live-region updates to announce. Defaults to new transcript rows only. |
| `aria-busy` | `boolean` | - | Marks the live region busy while a turn streams, if needed. |
| `spacerClassName` | `string` | - | Class name for the internal spacer used to make room for anchored rows. |
| `...props` | `React.ComponentProps<"div">` | - | Props spread to the content element. |
### MessageScroller.Item
One transcript row: a message, marker, typing row, separator, or load-more row.
| Prop | Type | Default | Description |
| -------------- | ----------------------------- | ------- | ------------------------------------------------------------------------------ |
| `messageId` | `string` | - | Stable row id used by `scrollToMessage`, visibility, and prepend preservation. |
| `scrollAnchor` | `boolean` | `false` | Marks this row as a turn boundary that can anchor newly appended turns. |
| `...props` | `React.ComponentProps<"div">` | - | Props spread to the item element. |
| Data attribute | Value | Description |
| -------------------- | --------------------- | ---------------------------------- |
| `data-message-id` | `string` | Mirrors `messageId` when provided. |
| `data-scroll-anchor` | `"true"` \| `"false"` | Mirrors `scrollAnchor`. |
### MessageScroller.Button
A button that scrolls to the start or end of the transcript. It is inert and
removed from the tab order when there is nothing to scroll toward.
| Prop | Type | Default | Description |
| ----------- | --------------------------------------- | ---------- | ------------------------------------------------------------------------ |
| `behavior` | `ScrollBehavior` | `"smooth"` | Native scroll behavior used when the button scrolls to its target edge. |
| `direction` | `"start" \| "end"` | `"end"` | Transcript edge the button scrolls toward. |
| `children` | `React.ReactNode` | - | Custom button content. Defaults to the scroll icon and accessible label. |
| `render` | `React.ReactElement \| render function` | - | Custom render target. |
| `...props` | `React.ComponentProps<"button">` | - | Props spread to the button. |
| Data attribute | Value | Description |
| ---------------- | --------------------- | ----------------------------------------- |
| `data-direction` | `"start"` \| `"end"` | Mirrors `direction`. |
| `data-active` | `"true"` \| `"false"` | Whether this button can currently scroll. |
### useMessageScroller
Imperative transcript controls.
| Method | Type | Description |
| ----------------- | ------------------------------------------ | ------------------------------- |
| `scrollToMessage` | `(messageId: string, options?) => boolean` | Scroll to a mounted message id. |
| `scrollToEnd` | `(options?) => boolean` | Scroll to the latest message. |
| `scrollToStart` | `(options?) => boolean` | Scroll to the top. |
All commands return `false` when the command could not be applied.
`scrollToStart` and `scrollToEnd` return `false` only when the viewport is not
mounted yet. `scrollToMessage` returns `false` when the target is not mounted and
cannot be queued.
Command options:
| Option | Type | Default | Description |
| -------------- | ------------------------------------------------- | ----------------------- | ---------------------------------------------------- |
| `align` | `"start"` \| `"center"` \| `"end"` \| `"nearest"` | `"start"` | How a message target aligns in the viewport. |
| `behavior` | `ScrollBehavior` | `"auto"` | Native scroll behavior for the command. |
| `scrollMargin` | `number` | provider `scrollMargin` | Margin applied to the aligned edge for this command. |
### useMessageScrollerScrollable
Which edges the viewport can scroll toward, for sibling UI that needs the values
in JavaScript. Prefer the `data-scrollable` attribute for styling the scroller
itself.
| Value | Type | Description |
| ------- | --------- | ------------------------------------------------------------------------------------------------------ |
| `start` | `boolean` | Whether the viewport can scroll toward the start. Content is hidden above (`!start` means at the top). |
| `end` | `boolean` | Whether the viewport can scroll toward the end. Content is hidden below (`!end` means at the bottom). |
### useMessageScrollerVisibility
Visibility state for outline, search, and active-turn UI. It subscribes
separately from `useMessageScrollerScrollable`, so visibility work is only paid for
when a consumer needs it.
| Value | Type | Description |
| ------------------- | ---------------- | ---------------------------------------------------------------------------------------------- |
| `currentAnchorId` | `string \| null` | The current anchored turn, based on the last `scrollAnchor` item at or above the reading line. |
| `visibleMessageIds` | `string[]` | Message ids intersecting the viewport, in document order. |
Filter `visibleMessageIds` in your app when you need a narrower outline, such as
user messages, anchored turns, or search hits.

View File

@@ -0,0 +1,4 @@
{
"title": "@shadcn/react",
"pages": ["message-scroller"]
}

View File

@@ -0,0 +1,422 @@
---
title: API Reference
description: Programmatic API for working with registries, schemas and presets.
---
The `shadcn` package exposes a set of programmatic APIs in addition to the CLI.
You can use these to fetch and resolve registry items, validate registry JSON,
and build custom tooling on top of the registry.
Each API is available under a dedicated subpath import.
```ts
import { getRegistryItems } from "shadcn/registry"
import { registryItemSchema } from "shadcn/schema"
```
<Callout className="mt-6">
The CLI commands themselves are not part of the public API. Only the imports
documented below are considered stable.
</Callout>
## shadcn/registry
Fetch and resolve items from configured registries.
Most functions accept an options object. The two options below are common to all
of them. In the examples that follow, `config` refers to this optional value —
omit it to use the built-in registries.
### config
- **Type:** `Partial<Config>`
- **Default:** built-in registries only
The resolved contents of your `components.json` file. Its `registries` field
maps a namespace (e.g. `@acme`) to a URL and any authentication headers or
environment variables required to reach it.
```ts showLineNumbers
import { getRegistryItems } from "shadcn/registry"
const items = await getRegistryItems(["@acme/login-form"], {
config: {
registries: {
"@acme": "https://acme.com/r/{name}.json",
},
},
})
```
### useCache
- **Type:** `boolean`
- **Default:** `true`
Registry responses are cached **in memory for the lifetime of the process**,
keyed by the resolved URL. Because the in-flight promise is cached, concurrent
requests for the same URL are de-duplicated into a single fetch.
Leave this enabled for one-off scripts and CLI runs. Set it to `false` in
long-running processes (servers, watchers, the MCP server) where the registry
can change between requests and you need fresh data each time.
```ts
const fresh = await getRegistry("@shadcn", { useCache: false })
```
### getRegistry
Fetch a single registry by name.
```ts showLineNumbers
import { getRegistry } from "shadcn/registry"
const registry = await getRegistry("@acme", {
config, // optional Partial<Config>
useCache: true,
})
```
### getRegistryItems
Fetch one or more registry items by their qualified names.
```ts showLineNumbers
import { getRegistryItems } from "shadcn/registry"
const items = await getRegistryItems(["@acme/button", "@acme/card"], {
config,
useCache: true,
})
```
Returns an array of registry items:
```json showLineNumbers
[
{
"name": "button",
"type": "registry:ui",
"dependencies": ["@radix-ui/react-slot"],
"files": [
{
"path": "ui/button.tsx",
"type": "registry:ui",
"content": "..."
}
]
}
]
```
### resolveRegistryItems
Resolve multiple items together with their registry dependencies, merged into a
single tree. Unlike [`getRegistryItems`](#getregistryitems), which returns the
items as a list, this walks each item's `registryDependencies` and flattens
everything — files, dependencies, CSS variables — into one installable object.
```ts showLineNumbers
import { resolveRegistryItems } from "shadcn/registry"
const tree = await resolveRegistryItems(
["@acme/button", "@acme/card", "@acme/dialog"],
{ config }
)
```
Returns a single merged tree:
```json showLineNumbers
{
"dependencies": ["@radix-ui/react-slot", "@radix-ui/react-dialog"],
"files": [
{ "path": "ui/button.tsx", "type": "registry:ui", "content": "..." },
{ "path": "ui/card.tsx", "type": "registry:ui", "content": "..." },
{ "path": "ui/dialog.tsx", "type": "registry:ui", "content": "..." }
],
"cssVars": {
"theme": {
"font-heading": "Poppins, sans-serif"
},
"light": {
"brand": "oklch(0.205 0.015 18)"
},
"dark": {
"brand": "oklch(0.205 0.015 18)"
}
},
"docs": ""
}
```
### getRegistries
Fetch the registry directory.
```ts showLineNumbers
import { getRegistries } from "shadcn/registry"
const registries = await getRegistries({ useCache: true })
```
Returns an array of registry entries:
```json
[
{
"name": "@shadcn",
"url": "https://ui.shadcn.com/r/{name}.json",
"homepage": "https://ui.shadcn.com"
}
]
```
### searchRegistries
Search across one or more registries with fuzzy matching.
```ts showLineNumbers
import { searchRegistries } from "shadcn/registry"
const results = await searchRegistries(["@shadcn"], {
query: "button",
types: ["registry:component"],
limit: 100,
offset: 0,
config,
continueOnError: true, // skip (don't throw on) registries that fail to load
})
```
Returns matching items wrapped in pagination metadata:
```json
{
"pagination": { "total": 1, "offset": 0, "limit": 100, "hasMore": false },
"items": [
{
"name": "button",
"type": "registry:ui",
"description": "A button component.",
"registry": "@shadcn",
"addCommandArgument": "@shadcn/button"
}
]
}
```
### loadRegistry
Read and resolve a local `registry.json` file from disk, following any
`include` references, and return the registry catalog.
```ts showLineNumbers
import { loadRegistry } from "shadcn/registry"
const catalog = await loadRegistry({
cwd: process.cwd(), // defaults to process.cwd()
registryFile: "registry.json", // defaults to "registry.json"
})
```
The returned catalog lists every item but **omits file contents** — like a
built `registry.json` index.
<Callout className="mt-6" title="How is this different from getRegistry?">
[`getRegistry`](#getregistry) fetches a **remote** registry over the network
(by namespace, URL or GitHub address) and expects the served catalog to
already be flattened — it rejects catalogs that still use `include`.
`loadRegistry` reads a **local** `registry.json` from disk and resolves
`include` references itself.
</Callout>
### loadRegistryItem
Read a single item from a local `registry.json` by name, with its file contents
read from disk and inlined.
```ts showLineNumbers
import { loadRegistryItem } from "shadcn/registry"
const item = await loadRegistryItem("login-form", { cwd: process.cwd() })
```
Returns a fully resolved registry item with file contents:
```json
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "login-form",
"type": "registry:component",
"files": [
{
"path": "registry/new-york/login-form.tsx",
"type": "registry:component",
"content": "..."
}
]
}
```
<Callout className="mt-6" title="How is this different from getRegistryItems?">
[`getRegistryItems`](#getregistryitems) resolves items from a **remote**
registry over the network. `loadRegistryItem` builds a single item on demand
from your **local** source files, reading each file from disk. Use it in a
dynamic route that serves `registry-item.json` responses.
</Callout>
### Errors
All registry functions throw typed errors that extend `RegistryError`. Use the
`RegistryErrorCode` enum or `instanceof` checks to handle them.
```ts showLineNumbers
import { RegistryError, RegistryNotFoundError } from "shadcn/registry"
try {
await getRegistry("@unknown")
} catch (error) {
if (error instanceof RegistryNotFoundError) {
// handle missing registry
}
}
```
Available error classes:
- `RegistryError`
- `RegistryNotFoundError`
- `RegistryUnauthorizedError`
- `RegistryForbiddenError`
- `RegistryFetchError`
- `RegistryNotConfiguredError`
- `RegistryLocalFileError`
- `RegistryParseError`
- `RegistryValidationError`
- `RegistryItemNotFoundError`
- `RegistriesIndexParseError`
- `RegistryMissingEnvironmentVariablesError`
- `RegistryInvalidNamespaceError`
## shadcn/schema
The Zod schemas used to validate `registry.json`, `registry-item.json` and
`components.json`. Useful for validating registry data in your own tooling.
```ts
import { registryItemSchema, registrySchema } from "shadcn/schema"
const result = registryItemSchema.safeParse(json)
if (!result.success) {
console.error(result.error)
}
```
Key schemas:
- `registrySchema`
- `registryItemSchema`
- `registryItemFileSchema`
- `registryItemTypeSchema`
- `registryItemCssVarsSchema`
- `registryItemTailwindSchema`
- `registryBaseColorSchema`
- `configSchema`
- `presetSchema`
Inferred types are exported alongside them:
- `Registry`
- `RegistryItem`
- `RegistryBaseItem`
- `RegistryFontItem`
- `Preset`
- `ConfigJson`
## shadcn/preset
Encode, decode and validate theme presets, plus the preset option constants used
by the theme editor.
### encodePreset
Encode a `Partial<PresetConfig>` into a short, URL-safe preset code. Any fields
you omit fall back to `DEFAULT_PRESET_CONFIG`.
```ts showLineNumbers
import { encodePreset } from "shadcn/preset"
const code = encodePreset({
style: "vega",
baseColor: "stone",
theme: "blue",
radius: "large",
font: "geist",
})
```
Returns a version-prefixed string:
```ts showLineNumbers
"bJ4FLU0"
```
### decodePreset
Decode a preset code back into a full `PresetConfig`. Returns `null` if the code
is missing or invalid.
```ts showLineNumbers
import { decodePreset } from "shadcn/preset"
const config = decodePreset("bJ4FLU0")
```
Returns the resolved config (omitted fields are filled with their defaults):
```json
{
"style": "vega",
"baseColor": "stone",
"theme": "blue",
"chartColor": "neutral",
"iconLibrary": "lucide",
"font": "geist",
"fontHeading": "inherit",
"radius": "large",
"menuAccent": "subtle",
"menuColor": "default"
}
```
```ts
decodePreset("not-a-code") // null
```
### Other exports
Additional functions for validating codes and generating random presets:
- `isPresetCode`
- `isValidPreset`
- `generateRandomConfig`
- `generateRandomPreset`
- `toBase62`
- `fromBase62`
Constants:
- `PRESET_BASES`
- `PRESET_STYLES`
- `PRESET_BASE_COLORS`
- `PRESET_THEMES`
- `PRESET_ICON_LIBRARIES`
- `PRESET_FONTS`
- `PRESET_FONT_HEADINGS`
- `PRESET_RADII`
- `PRESET_MENU_ACCENTS`
- `PRESET_MENU_COLORS`
- `PRESET_CHART_COLORS`
- `DEFAULT_PRESET_CONFIG`

View File

@@ -76,6 +76,35 @@ To add a new color you need to add it to `cssVars` under `light` and `dark` keys
The CLI will update the project CSS file. Once updated, the new colors will be available to be used as utility classes: `bg-brand` and `text-brand-accent`. The CLI will update the project CSS file. Once updated, the new colors will be available to be used as utility classes: `bg-brand` and `text-brand-accent`.
### Why does `button` in `registryDependencies` not resolve to my GitHub repository?
Bare registry dependency names keep the existing shadcn behavior. `button`
means the built-in shadcn `button` item.
For a dependency from a GitHub repository, use the full GitHub item address.
```json title="registry-item.json" showLineNumbers
{
"registryDependencies": ["acme/ui/button"]
}
```
### How do I pin a GitHub registry item?
Add `#ref` to the GitHub item address. The ref can be a branch, tag or full
commit SHA.
```bash
npx shadcn@latest add acme/ui/button#v1.2.0
```
For published registries, prefer tags or full commit SHAs.
### Can GitHub registry addresses use private repositories?
Not currently. GitHub registry addresses support public `github.com`
repositories. For private registries, use a namespace with authenticated URLs.
### How do I add or override a Tailwind theme variable? ### How do I add or override a Tailwind theme variable?
To add or override a theme variable you add it to `cssVars.theme` under the key you want to add or override. To add or override a theme variable you add it to `cssVars.theme` under the key you want to add or override.

View File

@@ -3,15 +3,19 @@ title: Getting Started
description: Learn how to get setup and run your own component registry. description: Learn how to get setup and run your own component registry.
--- ---
This guide will walk you through the process of setting up your own component registry. It assumes you already have a project with components and would like to turn it into a registry. This guide will walk you through the process of setting up your own registry. It assumes you already have a project with components, hooks, utilities or other files you would like to distribute.
**If you have an existing public GitHub repository, you can turn it into a
registry by adding a `registry.json` file at the root.** See
[GitHub Registries](/docs/registry/github) for details.
If you're starting a new registry project, you can use the [registry template](https://github.com/shadcn-ui/registry-template) as a starting point. We have already configured it for you. If you're starting a new registry project, you can use the [registry template](https://github.com/shadcn-ui/registry-template) as a starting point. We have already configured it for you.
## Requirements ## Requirements
You are free to design and host your custom registry as you see fit. The only requirement is that your registry catalog and registry items must be valid JSON files that conform to the [registry schema specification](/docs/registry/registry-json) and [registry-item schema specification](/docs/registry/registry-item-json). You are free to design and publish your custom registry as you see fit. The only requirement is that your registry catalog and registry items must conform to the [registry schema specification](/docs/registry/registry-json) and [registry-item schema specification](/docs/registry/registry-item-json).
Your registry can be a Next.js, Vite, Vue, Svelte, PHP or any other framework as long as it supports serving JSON over HTTP. Your registry can be a Next.js, Vite, Vue, Svelte, PHP or any other framework as long as it supports serving JSON over HTTP. It can also be a public GitHub repository with a `registry.json` file at the root.
If you'd like to see an example of a registry, we have a [template project](https://github.com/shadcn-ui/registry-template) for you to use as a starting point. If you'd like to see an example of a registry, we have a [template project](https://github.com/shadcn-ui/registry-template) for you to use as a starting point.
@@ -638,7 +642,7 @@ Here are some guidelines to follow when building components for a registry.
- Place your registry item in the `registry/[STYLE]/[NAME]` directory. I'm using `default` as an example. It can be anything you want as long as it's nested under the `registry` directory. - Place your registry item in the `registry/[STYLE]/[NAME]` directory. I'm using `default` as an example. It can be anything you want as long as it's nested under the `registry` directory.
- For blocks, the following properties are required: `name`, `description`, `type` and `files`. - For blocks, the following properties are required: `name`, `description`, `type` and `files`.
- It is recommended to add a proper name and description to your registry item. This helps LLMs understand the component and its purpose. - It is recommended to add a proper name and description to your registry item. This helps LLMs understand the component and its purpose.
- Make sure to list all registry dependencies in `registryDependencies`. A registry dependency is the name of the component in the registry eg. `input`, `button`, `card`, etc or a URL to a registry item eg. `http://localhost:3000/r/editor.json`. - Make sure to list all registry dependencies in `registryDependencies`. A registry dependency is an item address such as `button`, `@acme/input-form`, `acme/ui/button` or `http://localhost:3000/r/editor.json`.
- Make sure to list all dependencies in `dependencies`. A dependency is the name of the package in the registry eg. `zod`, `sonner`, etc. To set a version, you can use the `name@version` format eg. `zod@^3.20.0`. - Make sure to list all dependencies in `dependencies`. A dependency is the name of the package in the registry eg. `zod`, `sonner`, etc. To set a version, you can use the `name@version` format eg. `zod@^3.20.0`.
- **Imports should always use the `@/registry` path.** eg. `import { HelloWorld } from "@/registry/default/hello-world/hello-world"` - **Imports should always use the `@/registry` path.** eg. `import { HelloWorld } from "@/registry/default/hello-world/hello-world"`
- Ideally, place your files within a registry item in `components`, `hooks`, `lib` directories. - Ideally, place your files within a registry item in `components`, `hooks`, `lib` directories.

View File

@@ -0,0 +1,619 @@
---
title: GitHub Registries
description: Use a public GitHub repository as a registry.
---
You can now turn **any public GitHub repository into a registry.**
Add a `registry.json` file to the root of the repo, describe the files you want
to share, and users can install them with the `shadcn` CLI.
```bash
npx shadcn@latest add <username>/<repo>/<item>
```
You do not need to set up a registry server or publish generated JSON files. **The GitHub repository becomes the source registry.**
## Distribute Anything
Registry items are **not limited to components or React code.** They can include
any files from your repository: source files, configuration, docs, templates,
workflows, rules or project conventions.
<div className="not-prose my-6 overflow-hidden rounded-lg border text-sm">
<div className="hidden grid-cols-[220px_1fr] border-b bg-muted/50 px-4 py-3 font-medium md:grid">
<div>Use case</div>
<div>Example files</div>
</div>
{[
["Components", "components/date-picker.tsx", "components/data-table.tsx"],
[
"Helpers and utilities",
"lib/format-date.ts",
"lib/cn.ts",
"hooks/use-copy.ts",
],
[
"Design system packages",
"tokens/colors.json",
"styles/theme.css",
"components/*",
],
[
"Feature kits",
"app/(auth)/*",
"lib/auth.ts",
"components/login-form.tsx",
],
["Agent workflows", "AGENTS.md", ".cursor/rules/*", ".claude/commands/*"],
[
"Project conventions",
".editorconfig",
"biome.json",
"docs/conventions.md",
],
[
"Codemods and migration kits",
"codemods/*",
"scripts/migrate.ts",
"docs/migration.md",
],
["Testing setup", "vitest.config.ts", "test/setup.ts", "docs/testing.md"],
[
"CI and release workflows",
".github/workflows/ci.yml",
".github/workflows/release.yml",
],
[
"Project automation",
"scripts/release.ts",
"scripts/checks.ts",
"docs/automation.md",
],
[
"Issue and pull request templates",
".github/ISSUE_TEMPLATE/*",
".github/pull_request_template.md",
],
["MCP configuration", ".mcp.json", ".cursor/mcp.json"],
].map(([label, ...files]) => (
<div
className="grid gap-2 border-b px-4 py-3 last:border-b-0 md:grid-cols-[220px_1fr]"
key={label}
>
<div className="font-medium">{label}</div>
<div className="flex min-w-0 flex-wrap gap-1.5">
{files.map((file) => (
<code key={file}>{file}</code>
))}
</div>
</div>
))}
</div>
## When to use GitHub
Use a GitHub registry when:
- You already have reusable code in a public GitHub repository.
- You want users to install directly from `owner/repo/item`.
- You want to distribute config files, rules, docs, templates, utilities or
any other files from the same repository.
- You do not need private repo access or custom request authentication.
## Requirements
A GitHub registry must:
- Be a public `github.com` repository.
- Have a `registry.json` file at the repository root.
- Use valid `registry.json` and `registry-item.json` schemas.
- Reference source files that exist in the repository.
Private repositories and GitHub Enterprise hosts are not currently supported by
GitHub addresses. For private or authenticated registries, use a
[namespace](/docs/registry/namespace) with
[authentication](/docs/registry/authentication).
## Step 1: Add registry.json
Given an existing public repository:
```txt
.
├── ...
├── .editorconfig
├── AGENTS.md
└── docs
└── conventions.md
```
Add `registry.json` at the root of the repository.
```txt
.
├── ...
├── registry.json
├── .editorconfig
├── AGENTS.md
└── docs
└── conventions.md
```
Define the item you want to distribute.
```json title="registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme-toolkit",
"homepage": "https://github.com/acme/toolkit",
"items": [
{
"name": "project-conventions",
"type": "registry:item",
"title": "Project Conventions",
"description": "Shared project conventions, editor settings and agent instructions.",
"files": [
{
"path": "AGENTS.md",
"type": "registry:file",
"target": "~/AGENTS.md"
},
{
"path": ".editorconfig",
"type": "registry:file",
"target": "~/.editorconfig"
},
{
"path": "docs/conventions.md",
"type": "registry:file",
"target": "~/docs/conventions.md"
}
]
}
]
}
```
Commit and push the file.
```bash
git add registry.json
```
```bash
git commit -m "add registry"
```
```bash
git push
```
Users can now install the item from GitHub.
```bash
npx shadcn@latest add acme/toolkit/project-conventions
```
## Step 2: Distribute any file
A registry item can install one file or many files. Use the `files` array to
declare the files that belong together.
For example, a testing setup can install a Vitest config, a setup file and a
short team guide.
```txt
registry.json
config
└── vitest.config.ts
docs
└── testing.md
test
└── setup.ts
```
```json title="registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme-toolkit",
"homepage": "https://github.com/acme/toolkit",
"items": [
{
"name": "vitest-setup",
"type": "registry:item",
"title": "Vitest Setup",
"description": "A Vitest setup with project defaults and docs.",
"files": [
{
"path": "config/vitest.config.ts",
"type": "registry:file",
"target": "~/vitest.config.ts"
},
{
"path": "test/setup.ts",
"type": "registry:file",
"target": "~/test/setup.ts"
},
{
"path": "docs/testing.md",
"type": "registry:file",
"target": "~/docs/testing.md"
}
]
}
]
}
```
Users install it the same way.
```bash
npx shadcn@latest add acme/toolkit/vitest-setup
```
Use `target` when a file should be written to a specific destination in the
user's project.
```json title="registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme-toolkit",
"homepage": "https://github.com/acme/toolkit",
"items": [
{
"name": "editorconfig",
"type": "registry:file",
"files": [
{
"path": "config/.editorconfig",
"type": "registry:file",
"target": "~/.editorconfig"
}
]
}
]
}
```
```bash
npx shadcn@latest add acme/toolkit/editorconfig
```
## Step 3: Validate the registry
Before sharing the registry, validate it from the CLI.
```bash
npx shadcn@latest registry validate acme/toolkit
```
The command reads the root `registry.json`, resolves includes, validates the
registry items, and checks that referenced files exist.
You can also validate a branch, tag or commit SHA.
```bash
npx shadcn@latest registry validate acme/toolkit#v1.0.0
```
## Step 4: List and search items
Use `list` to see every item in the repository registry.
```bash
npx shadcn@latest list acme/toolkit
```
Use `search` to filter the catalog.
```bash
npx shadcn@latest search acme/toolkit --query conventions
```
Use `view` to inspect one item payload.
```bash
npx shadcn@latest view acme/toolkit/project-conventions
```
## Organize with include
For larger repositories, keep item definitions close to the source files they
describe.
```txt
registry.json
config
├── prettier.config.mjs
└── registry.json
rules
├── agent.md
└── registry.json
```
The root `registry.json` can include the nested registry files.
```json title="registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme-toolkit",
"homepage": "https://github.com/acme/toolkit",
"include": ["config/registry.json", "rules/registry.json"]
}
```
The included registry file declares items for that directory.
```json title="rules/registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"items": [
{
"name": "agent-rules",
"type": "registry:file",
"files": [
{
"path": "agent.md",
"type": "registry:file",
"target": "~/AGENTS.md"
}
]
}
]
}
```
When using `include`, file paths are relative to the `registry.json` file that
declares the item.
```bash
npx shadcn@latest add acme/toolkit/project-conventions
```
## Registry dependencies
Use `registryDependencies` when one registry item depends on another registry
item.
### Same repository dependencies
For dependencies in the same GitHub repository, use the full GitHub item
address.
```json title="registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme-toolkit",
"homepage": "https://github.com/acme/toolkit",
"items": [
{
"name": "project-setup",
"type": "registry:item",
"registryDependencies": [
"acme/toolkit/agent-rules",
"acme/toolkit/prettier-config",
"acme/toolkit/tsconfig"
],
"files": [
{
"path": "docs/project-setup.md",
"type": "registry:file",
"target": "~/docs/project-setup.md"
}
]
}
]
}
```
A docs item can depend on a template item from the same repository.
```json title="registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme-toolkit",
"homepage": "https://github.com/acme/toolkit",
"items": [
{
"name": "contributing-guide",
"type": "registry:item",
"registryDependencies": ["acme/toolkit/readme-template"],
"files": [
{
"path": "docs/contributing.md",
"type": "registry:file",
"target": "~/docs/contributing.md"
}
]
}
]
}
```
A CI setup can depend on the same formatting and testing defaults that users can
install separately.
```json title="registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme-toolkit",
"homepage": "https://github.com/acme/toolkit",
"items": [
{
"name": "ci-setup",
"type": "registry:item",
"registryDependencies": [
"acme/toolkit/prettier-config",
"acme/toolkit/vitest-setup"
],
"files": [
{
"path": ".github/workflows/ci.yml",
"type": "registry:file",
"target": "~/.github/workflows/ci.yml"
}
]
}
]
}
```
### External registry dependencies
Items can also depend on external registries. Use the full item address for the
registry that owns the dependency.
```json title="registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme-toolkit",
"homepage": "https://github.com/acme/toolkit",
"items": [
{
"name": "workspace-setup",
"type": "registry:item",
"registryDependencies": [
"@acme/tsconfig",
"contoso/devtools/prettier-config"
],
"files": [
{
"path": "docs/workspace.md",
"type": "registry:file",
"target": "~/docs/workspace.md"
}
]
}
]
}
```
### Dependency refs
Refs are not inherited across dependencies. If a dependency should be pinned,
include its own ref.
```json title="registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme-toolkit",
"homepage": "https://github.com/acme/toolkit",
"items": [
{
"name": "project-setup",
"type": "registry:item",
"registryDependencies": [
"acme/toolkit/agent-rules#v1.0.0",
"acme/toolkit/tsconfig#c0ffee254729296a45d6691db565cf707a3fef5d"
],
"files": [
{
"path": "docs/project-setup.md",
"type": "registry:file",
"target": "~/docs/project-setup.md"
}
]
}
]
}
```
## Useful commands
List every item in a GitHub registry.
```bash
npx shadcn@latest list acme/toolkit
```
Search a GitHub registry.
```bash
npx shadcn@latest search acme/toolkit -q conventions
```
Validate a GitHub registry.
```bash
npx shadcn@latest registry validate acme/toolkit
```
Install an item from a GitHub registry.
```bash
npx shadcn@latest add acme/toolkit/project-conventions
```
View an item from a GitHub registry.
```bash
npx shadcn@latest view acme/toolkit/project-conventions
```
Install an item whose registry item name contains `/`.
```bash
npx shadcn@latest add acme/toolkit/rules/agent
```
<Callout>
For GitHub item addresses, the first two path segments are the GitHub owner
and repository. Any remaining segments are the registry item name, not a file
path. An address ending in `.json` is treated as a file path.
</Callout>
Install from a tag.
```bash
npx shadcn@latest add acme/toolkit/project-conventions#v1.0.0
```
Install from a full commit SHA.
```bash
npx shadcn@latest add acme/toolkit/project-conventions#c0ffee254729296a45d6691db565cf707a3fef5d
```
## Refs
Use `#ref` to install from a branch, tag or commit SHA.
```bash
npx shadcn@latest add acme/toolkit/project-conventions#main
```
Refs may contain slashes.
```bash
npx shadcn@latest add acme/toolkit/project-conventions#feature/conventions
```
If no ref is provided, the CLI uses the repository default branch.
The CLI uses Git to resolve branches, tags and short refs into a commit SHA
before reading files. Full 40-character commit SHAs are used directly and do not
require Git.
## Review before installing
GitHub registry items install code and project files from public repositories.
Treat a GitHub item address like any other third-party code dependency.
Before installing from a source you do not control:
- Review the repository and the root `registry.json`.
- Review the item definition, especially `files`, `target`, `dependencies`,
`devDependencies`, `registryDependencies` and `envVars`.
- Check any external registry dependencies. They can install files from other
registries.
- Prefer pinned refs for published install commands. A full 40-character commit
SHA is the most reproducible option.
- Use `shadcn view acme/toolkit/project-conventions` to inspect the resolved
item payload before installing.
- Pipe `shadcn view` output to your agent or review tool if you want help
checking the item.
- Use `shadcn add acme/toolkit/project-conventions --dry-run` to preview an
install without writing files.
- Use `--diff` or `--view` with `shadcn add` to inspect file changes or file
contents before applying them.

View File

@@ -33,12 +33,32 @@ You can use the `shadcn` CLI to run your own code registry. Running your own reg
Ready to create your own registry? In the next section, we'll walk you through setting up your own custom registry step-by-step, from creating your first component to publishing it for others to use. Ready to create your own registry? In the next section, we'll walk you through setting up your own custom registry step-by-step, from creating your first component to publishing it for others to use.
<div className="mt-6 grid gap-4 sm:grid-cols-2"> <div className="mt-6 grid gap-4 sm:grid-cols-2">
<LinkedCard href="/docs/registry/getting-started" className="items-start text-sm md:p-6"> <LinkedCard
href="/docs/registry/getting-started"
className="items-start text-sm md:p-6"
>
<div className="font-medium">Getting Started</div> <div className="font-medium">Getting Started</div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
Set up and build your own registry Set up and build your own registry
</div> </div>
</LinkedCard> </LinkedCard>
<LinkedCard href="/docs/registry/github" className="items-start text-sm md:p-6">
<div className="font-medium">GitHub</div>
<div className="text-muted-foreground">
Turn a GitHub repository into a registry
</div>
</LinkedCard>
<LinkedCard
href="/docs/registry/namespace"
className="items-start text-sm md:p-6"
>
<div className="font-medium">Namespaces</div>
<div className="text-muted-foreground">
Configure registries with namespaces
</div>
</LinkedCard>
<LinkedCard <LinkedCard
href="/docs/registry/authentication" href="/docs/registry/authentication"
@@ -49,31 +69,22 @@ Ready to create your own registry? In the next section, we'll walk you through s
Secure your registry with authentication Secure your registry with authentication
</div> </div>
</LinkedCard> </LinkedCard>
<LinkedCard
href="/docs/registry/namespace"
className="items-start text-sm md:p-6"
>
<div className="font-medium">Namespaces</div>
<div className="text-muted-foreground">
Configure registries with namespaces
</div>
</LinkedCard>
<LinkedCard <LinkedCard
href="/docs/registry/examples" href="/docs/registry/examples"
className="items-start text-sm md:p-6" className="items-start text-sm md:p-6"
> >
<div className="font-medium">Examples</div> <div className="font-medium">Examples</div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">Browse example registry items</div>
Registry item examples and configurations
</div>
</LinkedCard> </LinkedCard>
<LinkedCard
<LinkedCard
href="/docs/registry/registry-json" href="/docs/registry/registry-json"
className="items-start text-sm md:p-6" className="items-start text-sm md:p-6"
> >
<div className="font-medium">Schema</div> <div className="font-medium">Schema</div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
Schema specification for registry.json Schema specification for registry.json
</div> </div>
</LinkedCard> </LinkedCard>
</div> </div>

View File

@@ -3,12 +3,14 @@
"pages": [ "pages": [
"index", "index",
"getting-started", "getting-started",
"github",
"registry-index", "registry-index",
"examples", "examples",
"namespace", "namespace",
"authentication", "authentication",
"mcp", "mcp",
"open-in-v0", "open-in-v0",
"api-reference",
"registry-json", "registry-json",
"registry-item-json" "registry-item-json"
] ]

View File

@@ -156,6 +156,28 @@ The pattern for referencing resources is: `@namespace/resource-name`
--- ---
## GitHub and Namespaces
GitHub registry addresses and namespaces solve different problems.
Use a GitHub address when the registry is a public GitHub repository and you
want users to install without configuring `components.json`.
```bash
npx shadcn@latest add acme/ui/button
```
Use a namespace when you want a stable alias, custom hosting, authentication,
request headers, query parameters or private registry support.
```bash
npx shadcn@latest add @acme/button
```
See the [GitHub registry](/docs/registry/github) docs for more information.
---
## Configuration ## Configuration
Namespaced registries are configured in your `components.json` file under the `registries` field. Namespaced registries are configured in your `components.json` file under the `registries` field.

View File

@@ -9,6 +9,10 @@ When you run `shadcn add` or `shadcn search`, the CLI will automatically check t
You can see the full list at [https://ui.shadcn.com/r/registries.json](https://ui.shadcn.com/r/registries.json). You can see the full list at [https://ui.shadcn.com/r/registries.json](https://ui.shadcn.com/r/registries.json).
You do not need to submit a public GitHub registry to the registry directory to
use it with `owner/repo/item` addresses. The registry directory is for
namespaces such as `@acme`.
## Adding a Registry ## Adding a Registry
1. Add your registry to [`apps/v4/registry/directory.json`](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/directory.json) 1. Add your registry to [`apps/v4/registry/directory.json`](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/directory.json)

View File

@@ -161,23 +161,34 @@ Use `@version` to specify the version of the package.
### registryDependencies ### registryDependencies
Used for registry dependencies. Can be names, namespaced or URLs. Used for registry dependencies. Each entry is an item address.
- For `shadcn/ui` registry items such as `button`, `input`, `select`, etc use the name eg. `['button', 'input', 'select']`. - For `shadcn/ui` registry items such as `button`, `input`, `select`, etc use the name eg. `['button', 'input', 'select']`.
- For namespaced registry items such as `@acme` use the name eg. `['@acme/input-form']`. - For namespaced registry items, use `@namespace/item-name` eg. `['@acme/input-form']`.
- For GitHub registry items, use `owner/repo/item-name` eg. `['acme/ui/button']`. For published registries, prefer a tag or full commit SHA eg. `['acme/ui/button#v1.2.0']`.
- For custom registry items use the URL of the registry item eg. `['https://example.com/r/hello-world.json']`. - For custom registry items use the URL of the registry item eg. `['https://example.com/r/hello-world.json']`.
- For local registry item files use a file path eg. `['./hello-world.json']`.
```json title="registry-item.json" showLineNumbers ```json title="registry-item.json" showLineNumbers
{ {
"registryDependencies": [ "registryDependencies": [
"button", "button",
"@acme/input-form", "@acme/input-form",
"https://example.com/r/editor.json" "acme/ui/button#v1.2.0",
"https://example.com/r/editor.json",
"./editor.json"
] ]
} }
``` ```
Note: The CLI will automatically resolve remote registry dependencies. Note: Bare names keep their existing behavior. `button` means the built-in
shadcn `button` item, not an item from the same GitHub repository. For
same-repository GitHub dependencies, use the full GitHub item address.
Refs are not inherited across dependencies. If a GitHub dependency should be
reproducible, pin that dependency to its own tag or full commit SHA.
See the [GitHub registry](/docs/registry/github) docs for more information.
### files ### files

View File

@@ -49,6 +49,11 @@ using `include`.
} }
``` ```
Public GitHub repositories use the same source registry format. The CLI reads
the root `registry.json`, resolves `include`, and installs files from the
repository. See the [GitHub registry](/docs/registry/github) docs for more
information.
## Definitions ## Definitions
You can see the JSON Schema for `registry.json` [here](https://ui.shadcn.com/schema/registry.json). You can see the JSON Schema for `registry.json` [here](https://ui.shadcn.com/schema/registry.json).

View File

@@ -0,0 +1,4 @@
{
"title": "Utilities",
"pages": ["scroll-fade", "shimmer"]
}

View File

@@ -0,0 +1,176 @@
---
title: scroll-fade
description: Utilities for adding a fade effect to the edges of a scroll container.
---
<ComponentPreview
styleName="radix-rhea"
name="scroll-fade-demo"
previewClassName="h-auto"
/>
## Installation
If your project was set up with `npx shadcn@latest init`, you already have `scroll-fade`. It ships with the `shadcn` package, which the CLI imports in your global CSS file.
Otherwise, install the `shadcn` package:
```bash
npm install shadcn
```
Then import the shared utilities in your global CSS file:
```css
@import "tailwindcss";
@import "shadcn/tailwind.css";
```
## Usage
| Class | Styles |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `scroll-fade` | `mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));` <br /> `animation-timeline: scroll(self y);` |
| `scroll-fade-y` | `mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));` <br /> `animation-timeline: scroll(self y);` |
| `scroll-fade-x` | `mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));` <br /> `animation-timeline: scroll(self inline);` |
| `scroll-fade-t` | Fade mask on the top edge. <br /> `animation-timeline: scroll(self y);` |
| `scroll-fade-b` | Fade mask on the bottom edge. <br /> `animation-timeline: scroll(self y);` |
| `scroll-fade-l` | Fade mask on the left edge. <br /> `animation-timeline: scroll(self x);` |
| `scroll-fade-r` | Fade mask on the right edge. <br /> `animation-timeline: scroll(self x);` |
| `scroll-fade-s` | Fade mask on the start edge, mirrors in RTL. <br /> `animation-timeline: scroll(self inline);` |
| `scroll-fade-e` | Fade mask on the end edge, mirrors in RTL. <br /> `animation-timeline: scroll(self inline);` |
| `scroll-fade-<number>` | `--scroll-fade-size: calc(var(--spacing) * <number>);` |
| `scroll-fade-[<value>]` | `--scroll-fade-size: <value>;` |
| `scroll-fade-{t,b,s,e}-<number>` | `--scroll-fade-{t,b,s,e}-size: calc(var(--spacing) * <number>);` |
| `scroll-fade-{t,b,s,e}-[<value>]` | `--scroll-fade-{t,b,s,e}-size: <value>;` |
| `scroll-fade-none` | `--scroll-fade-mask: none;` |
Add `scroll-fade` or `scroll-fade-y` to the scroll container, i.e. the element that has `overflow-y-auto`.
```tsx
<div className="scroll-fade overflow-y-auto">{/* ... */}</div>
```
The fade is scroll-aware and tracks the scroll position:
- At rest, the top edge is crisp and the bottom edge fades to hint at more content.
- As you scroll, a fade appears at the top and both edges stay faded mid-scroll.
- At the end, the bottom edge sharpens to show you have reached the last item.
The fade is applied with `mask-image`, so it dissolves the content itself rather than overlaying a color. The mask uses a linear fade from transparent to black, so it adapts to any background without configuration. If your scroll area sits inside a card, put the background and border on a wrapper and `scroll-fade` on the inner scroller, so the fade dissolves the content and not the card.
The [`ScrollArea`](/docs/components/scroll-area) and [`MessageScroller`](/docs/components/message-scroller) components can use `scroll-fade` on their scrollable viewport.
## No Overflow, No Fade
If the content does not overflow, no fade is shown. You can apply `scroll-fade` to any list without checking whether it scrolls.
<ComponentPreview
styleName="radix-rhea"
name="scroll-fade-overflow"
previewClassName="h-auto"
/>
## Horizontal Scrolling
Use `scroll-fade-x` on containers that scroll horizontally, i.e. the element that has `overflow-x-auto`.
<ComponentPreview
styleName="radix-rhea"
name="scroll-fade-horizontal"
previewClassName="h-64"
/>
```tsx
<div className="flex scroll-fade-x overflow-x-auto">{/* ... */}</div>
```
The horizontal fade is direction-aware. In RTL layouts, the crisp edge and the fade follow the reading direction with no extra classes needed. `scroll-fade-<number>` and `scroll-fade-none` work the same for both axes.
## Edge Fades
Use edge utilities when only one edge should track the scroll position.
<ComponentPreview
styleName="radix-rhea"
name="scroll-fade-edge"
previewClassName="h-auto"
/>
```tsx
<div className="scroll-fade-b overflow-y-auto">{/* ... */}</div>
```
The edge utilities are scroll-aware. Start edges fade in after you scroll away from the start, and end edges fade out when you reach the end. Use `scroll-fade-t`, `scroll-fade-b`, `scroll-fade-l`, and `scroll-fade-r` for physical edges. Use `scroll-fade-s` and `scroll-fade-e` for logical inline edges that mirror in RTL.
## Fade Size
The fade depth defaults to `12%` of the container, capped at `40px` so tall scrollers stay subtle. Use `scroll-fade-<number>` to set a fixed size on the spacing scale instead, the same way `scroll-mt-<number>` works.
<ComponentPreview
styleName="radix-rhea"
name="scroll-fade-size"
previewClassName="h-auto"
/>
```tsx
<div className="scroll-fade overflow-y-auto scroll-fade-24">{/* ... */}</div>
```
For one-off values, use an arbitrary length or percentage:
```tsx
<div className="scroll-fade overflow-y-auto scroll-fade-[15%]">{/* ... */}</div>
```
To fade opposite edges by different amounts, use the per-edge modifiers `scroll-fade-t-<number>`, `scroll-fade-b-<number>`, `scroll-fade-s-<number>`, and `scroll-fade-e-<number>`. They override `scroll-fade-<number>` on the edge they target and accept arbitrary values too.
```tsx
<div className="scroll-fade overflow-y-auto scroll-fade-b-8 scroll-fade-t-2">
{/* ... */}
</div>
```
Use the logical `s`/`e` modifiers for horizontal scrollers so the sizes mirror in RTL.
The fade eases in and out over a fixed scroll distance rather than appearing instantly. That distance is the `--scroll-fade-reveal` variable, `96px` by default and independent of the fade depth. Lower it for a snappier reveal or raise it for a more gradual one:
```tsx
<div className="scroll-fade overflow-y-auto [--scroll-fade-reveal:64px]">
{/* ... */}
</div>
```
## Disabling the Fade
Use `scroll-fade-none` to remove the fade. It works in any class order, so the typical use is responsive or stateful:
```tsx
<div className="scroll-fade overflow-y-auto md:scroll-fade-none">
{/* ... */}
</div>
```
<ComponentPreview
styleName="radix-rhea"
name="scroll-fade-none"
previewClassName="h-auto"
/>
## Fallback
The scroll-aware behavior is implemented with [CSS scroll-driven animations](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll-driven_animations), with no JavaScript and no scroll listeners. In browsers that do not support scroll-driven animations, `scroll-fade` falls back to a static fade on both edges, and edge utilities fall back to a static fade on the selected edge.
Since the mask is applied to the scroll container itself, a visible scrollbar fades with the content at the edges. Pair `scroll-fade` with `no-scrollbar`, which ships in the same package, if you want to hide the scrollbar entirely.
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](/docs/rtl).
`scroll-fade-x` follows the reading direction. At rest, the start edge is crisp and the end edge fades. In RTL layouts that means a crisp right edge and a fade on the left, mirrored from LTR.
<ComponentPreview
styleName="radix-nova"
name="scroll-fade-rtl"
direction="rtl"
/>

View File

@@ -0,0 +1,167 @@
---
title: shimmer
description: Utilities for adding a shimmer effect to text elements.
---
<ComponentPreview styleName="radix-rhea" name="shimmer-demo" />
## Installation
If your project was set up with `npx shadcn@latest init`, you already have `shimmer`. It ships with the `shadcn` package, which the CLI imports in your global CSS file.
Otherwise, install the `shadcn` package:
```bash
npm install shadcn
```
Then import the shared utilities in your global CSS file:
```css
@import "tailwindcss";
@import "shadcn/tailwind.css";
```
## Usage
| Class | Styles |
| ----------------------------- | ---------------------------------------------------------------------------------------------------- |
| `shimmer` | `background-clip: text;` <br /> `animation: tw-shimmer var(--shimmer-duration, 2s) linear infinite;` |
| `shimmer-once` | `animation-iteration-count: 1;` |
| `shimmer-reverse` | `animation-direction: reverse;` |
| `shimmer-none` | `--shimmer-image: none;` <br /> `--shimmer-text-fill: currentColor;` |
| `shimmer-color-<color>` | `--shimmer-color: <color>;` |
| `shimmer-color-[<value>]` | `--shimmer-color: <value>;` |
| `shimmer-color-<color>/<pct>` | `--shimmer-color: color-mix(in oklch, <color> <pct>, transparent);` |
| `shimmer-duration-<number>` | `--shimmer-duration: calc(<number> * 1ms);` |
| `shimmer-spread-<number>` | `--shimmer-spread: calc(var(--spacing) * <number>);` |
| `shimmer-spread-[<value>]` | `--shimmer-spread: <value>;` |
| `shimmer-angle-<number>` | `--shimmer-angle: calc(<number> * 1deg);` |
Add `shimmer` to a text element.
```tsx
<p className="shimmer text-muted-foreground">Generating response&hellip;</p>
```
The shimmer is built on `currentColor`, so it adapts to the element:
- The highlight is derived from the text color, with no configuration needed.
- It works on any color, from `text-muted-foreground` to brand colors.
- In dark mode, the highlight automatically brightens to stay visible.
The effect is pure CSS. The text is painted with `background-clip: text`, and the highlight sweeps across it in a seamless loop.
## With Marker
The shimmer composes with any component that renders text. A common pattern is a [Marker](/docs/components/marker) showing a live status while the assistant is working:
<ComponentPreview styleName="radix-rhea" name="shimmer-marker" />
```tsx
<Marker role="status">
<MarkerIcon>
<Spinner />
</MarkerIcon>
<MarkerContent className="shimmer">Thinking&hellip;</MarkerContent>
</Marker>
```
## Color
Use `shimmer-color-<color>` to set the highlight color explicitly. It accepts theme colors with an optional opacity modifier, or any arbitrary color value.
<ComponentPreview styleName="radix-rhea" name="shimmer-color" />
```tsx
<p className="shimmer shimmer-color-blue-500/60">Generating response&hellip;</p>
<p className="shimmer shimmer-color-[#378ADD]">Generating response&hellip;</p>
```
## Duration
Use `shimmer-duration-<number>` to set the duration of one sweep in milliseconds. The default is `2000`, i.e. `2s`.
<ComponentPreview styleName="radix-rhea" name="shimmer-duration" />
```tsx
<p className="shimmer shimmer-duration-1000">Generating response&hellip;</p>
```
## Spread
Use `shimmer-spread-<number>` to set the width of the highlight band using the spacing scale. The default is `calc(3ch + 40px)`: a fixed base plus a `3ch` term that scales with the font size.
<ComponentPreview styleName="radix-rhea" name="shimmer-spread" />
```tsx
<p className="shimmer shimmer-spread-24">Generating response&hellip;</p>
```
For one-off values, use an arbitrary length or percentage:
```tsx
<p className="shimmer shimmer-spread-[5rem]">Generating response&hellip;</p>
```
## Angle
Use `shimmer-angle-<number>` to set the tilt of the highlight band in degrees. The default is `20`.
<ComponentPreview styleName="radix-rhea" name="shimmer-angle" />
```tsx
<p className="shimmer shimmer-angle-45">Generating response&hellip;</p>
```
## Reverse
Use `shimmer-reverse` to sweep the highlight in the opposite direction. In RTL layouts the sweep already follows the reading direction. See [RTL](#rtl).
```tsx
<p className="shimmer shimmer-reverse">Generating response&hellip;</p>
```
## Play Once
Use `shimmer-once` to play a single sweep instead of looping, useful as a reveal when streaming completes. Pair it with `shimmer-duration-<number>` to control how long the sweep takes.
<ComponentPreview styleName="radix-rhea" name="shimmer-once" />
```tsx
<p className="shimmer shimmer-duration-1100 shimmer-once">
Response generated.
</p>
```
## Disabling the Shimmer
Use `shimmer-none` to turn the effect off and render the text normally. It works in any class order, so the typical use is responsive or stateful:
<ComponentPreview styleName="radix-rhea" name="shimmer-none" />
```tsx
<p className="shimmer md:shimmer-none">Generating response&hellip;</p>
```
## Fallback
The shimmer is built on modern color features, [relative color syntax](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_colors/Relative_colors) and `color-mix()`, which are available in all current browsers. In older browsers without support, the highlight gradient is dropped and the text can render transparent. If you target older browsers, apply `shimmer` conditionally with a `supports-*` variant:
```tsx
<p className="supports-[color:oklch(from_white_l_c_h)]:shimmer">
Generating response&hellip;
</p>
```
## Reduced Motion
When the user prefers reduced motion, the animation is disabled automatically and the text renders normally. There is nothing to configure.
## RTL
To enable RTL support in shadcn/ui, see the [RTL configuration guide](/docs/rtl).
The sweep follows the reading direction, left to right in LTR and right to left in RTL, with no extra classes. Use `shimmer-reverse` to flip the direction manually.
<ComponentPreview styleName="radix-rhea" name="shimmer-rtl" />

View File

@@ -19,6 +19,7 @@ const eslintConfig = tseslint.config(
"next-env.d.ts", "next-env.d.ts",
".source/**", ".source/**",
"**/__index__.tsx", "**/__index__.tsx",
"**/__components__.tsx",
], ],
}, },
{ {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
import { FileCodeIcon, XIcon } from "lucide-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@/styles/base-rhea/ui/attachment"
import { Spinner } from "@/styles/base-rhea/ui/spinner"
const images = [
{
name: "workspace.png",
meta: "PNG · 820 KB",
src: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80",
alt: "Workspace",
},
{
name: "desk-reference.jpg",
meta: "JPG · 1.1 MB",
src: "https://images.unsplash.com/photo-1497215728101-856f4ea42174?w=900&auto=format&fit=crop&q=80",
alt: "Desk",
},
{
name: "office-reference.jpg",
meta: "JPG · 940 KB",
src: "https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=900&auto=format&fit=crop&q=80",
alt: "Office",
},
]
export function AttachmentDemo() {
return (
<div className="mx-auto flex w-full max-w-sm flex-col gap-3 py-12">
<AttachmentGroup>
{images.map((image) => (
<Attachment key={image.name} orientation="vertical">
<AttachmentMedia variant="image">
<img src={image.src} alt={image.alt} />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{image.name}</AttachmentTitle>
<AttachmentDescription>{image.meta}</AttachmentDescription>
</AttachmentContent>
</Attachment>
))}
</AttachmentGroup>
<Attachment state="uploading" className="w-full">
<AttachmentMedia>
<Spinner />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
<AttachmentDescription>Uploading · 64%</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Cancel upload">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment className="w-full">
<AttachmentMedia>
<FileCodeIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>message-renderer.tsx</AttachmentTitle>
<AttachmentDescription>TypeScript · 12 KB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove message-renderer.tsx">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import {
FileCodeIcon,
FileTextIcon,
TableIcon,
XIcon,
type LucideIcon,
} from "lucide-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@/styles/base-rhea/ui/attachment"
type Item = {
name: string
meta: string
icon?: LucideIcon
src?: string
}
const items: Item[] = [
{ name: "briefing-notes.pdf", meta: "PDF · 1.4 MB", icon: FileTextIcon },
{
name: "workspace.png",
meta: "PNG · 820 KB",
src: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80",
},
{ name: "customers.csv", meta: "CSV · 18 KB", icon: TableIcon },
{ name: "renderer.tsx", meta: "TSX · 12 KB", icon: FileCodeIcon },
]
export function AttachmentGroupDemo() {
return (
<div className="mx-auto w-full max-w-sm py-12">
<AttachmentGroup className="w-full">
{items.map((item) => {
const Icon = item.icon
return (
<Attachment key={item.name} className="w-64">
{item.src ? (
<AttachmentMedia variant="image">
<img src={item.src} alt={item.name} />
</AttachmentMedia>
) : Icon ? (
<AttachmentMedia>
<Icon />
</AttachmentMedia>
) : null}
<AttachmentContent>
<AttachmentTitle>{item.name}</AttachmentTitle>
<AttachmentDescription>{item.meta}</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label={`Remove ${item.name}`}>
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
)
})}
</AttachmentGroup>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { XIcon } from "lucide-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/styles/base-rhea/ui/attachment"
const images = [
{
name: "workspace.png",
meta: "PNG · 820 KB",
src: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80",
alt: "Workspace",
},
{
name: "desk-reference.jpg",
meta: "JPG · 1.1 MB",
src: "https://images.unsplash.com/photo-1497215728101-856f4ea42174?w=900&auto=format&fit=crop&q=80",
alt: "Desk",
},
{
name: "office-reference.jpg",
meta: "JPG · 940 KB",
src: "https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=900&auto=format&fit=crop&q=80",
alt: "Office",
},
]
export function AttachmentImage() {
return (
<div className="mx-auto w-full max-w-sm py-12">
<AttachmentGroup className="w-full">
{images.map((image) => (
<Attachment key={image.name} orientation="vertical">
<AttachmentMedia variant="image">
<img src={image.src} alt={image.alt} />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{image.name}</AttachmentTitle>
<AttachmentDescription>{image.meta}</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label={`Remove ${image.name}`}>
<XIcon />
</AttachmentAction>
</AttachmentActions>
<AttachmentTrigger
render={
<a
href={image.src}
target="_blank"
rel="noreferrer"
aria-label={`Open ${image.name}`}
/>
}
/>
</Attachment>
))}
</AttachmentGroup>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { FileTextIcon } from "lucide-react"
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/styles/base-rhea/ui/attachment"
export function AttachmentSizes() {
return (
<div className="mx-auto flex w-full max-w-sm flex-col gap-3 py-12">
<Attachment size="default" className="w-full">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>Default attachment</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment size="sm" className="w-full">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>Small attachment</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment size="xs" className="w-full">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>Extra small attachment</AttachmentTitle>
</AttachmentContent>
</Attachment>
</div>
)
}

View File

@@ -0,0 +1,101 @@
import {
CheckIcon,
ClockIcon,
FileTextIcon,
FileWarningIcon,
RefreshCwIcon,
XIcon,
} from "lucide-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/styles/base-rhea/ui/attachment"
import { Spinner } from "@/styles/base-rhea/ui/spinner"
export function AttachmentStates() {
return (
<div className="mx-auto flex w-full max-w-sm flex-col gap-2 py-12">
<Attachment state="idle" className="w-full">
<AttachmentMedia>
<ClockIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>selected-file.pdf</AttachmentTitle>
<AttachmentDescription>Ready to upload</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove selected-file.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="uploading" className="w-full">
<AttachmentMedia>
<Spinner />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>design-system.zip</AttachmentTitle>
<AttachmentDescription>Uploading · 64%</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Cancel upload">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="processing" className="w-full">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>market-research.pdf</AttachmentTitle>
<AttachmentDescription>Processing document</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove market-research.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="error" className="w-full">
<AttachmentMedia>
<FileWarningIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>financial-model.xlsx</AttachmentTitle>
<AttachmentDescription>
Upload failed. Try again.
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Retry upload">
<RefreshCwIcon />
</AttachmentAction>
<AttachmentAction aria-label="Remove financial-model.xlsx">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="done" className="w-full">
<AttachmentMedia>
<CheckIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>uploaded-report.pdf</AttachmentTitle>
<AttachmentDescription>Uploaded · 1.8 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove uploaded-report.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { CopyIcon, FileSearchIcon, XIcon } from "lucide-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/styles/base-rhea/ui/attachment"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/styles/base-rhea/ui/dialog"
export function AttachmentTriggerDemo() {
return (
<div className="mx-auto w-full max-w-sm py-12">
<Dialog>
<Attachment className="w-full">
<AttachmentMedia>
<FileSearchIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>research-summary.pdf</AttachmentTitle>
<AttachmentDescription>Open preview dialog</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Copy link">
<CopyIcon />
</AttachmentAction>
<AttachmentAction aria-label="Remove research-summary.pdf">
<XIcon />
</AttachmentAction>
</AttachmentActions>
<DialogTrigger
render={
<AttachmentTrigger aria-label="Preview research-summary.pdf" />
}
/>
</Attachment>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>research-summary.pdf</DialogTitle>
<DialogDescription>
The attachment trigger fills the card and opens the dialog, while
the actions stay independently clickable above it.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,18 @@
import { Bubble, BubbleContent } from "@/styles/base-rhea/ui/bubble"
export function BubbleAlignmentDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Bubble variant="muted">
<BubbleContent>
This bubble is aligned to the start. This is the default alignment.
</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>
This bubble is aligned to the end. Use this for user messages.
</BubbleContent>
</Bubble>
</div>
)
}

View File

@@ -0,0 +1,59 @@
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Bubble, BubbleContent } from "@/styles/base-rhea/ui/bubble"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Collapsible,
CollapsibleTrigger,
} from "@/styles/base-rhea/ui/collapsible"
const text = `The accessibility review found two focus states that were visually too subtle in dark mode.
I checked the dialog, menu, and drawer paths because each one renders focusable controls inside a layered surface.
The dialog and drawer are fine. The menu needs the hover and focus tokens split so keyboard focus stays visible when the pointer is not involved.
I also recommend keeping the change in the style file instead of the primitive so the other themes can choose their own focus treatment later.`
const previewLength = 180
export function BubbleCollapsible() {
const [open, setOpen] = React.useState(false)
const isLong = text.length > previewLength
const preview = `${text.slice(0, previewLength)}...`
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Bubble variant="muted">
<BubbleContent>How can I help you today?</BubbleContent>
</Bubble>
<Bubble variant="muted" align="end">
<BubbleContent className="whitespace-pre-line">
<Collapsible open={open} onOpenChange={setOpen}>
<div>{open || !isLong ? text : preview}</div>
{isLong ? (
<CollapsibleTrigger
render={
<Button
variant="link"
className="gap-1 p-0 text-muted-foreground"
/>
}
>
{open ? "Show less" : "Show more"}
<ChevronDownIcon
data-icon="inline-end"
className="group-data-panel-open/button:rotate-180"
/>
</CollapsibleTrigger>
) : null}
</Collapsible>
</BubbleContent>
</Bubble>
</div>
)
}

View File

@@ -0,0 +1,48 @@
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@/styles/base-rhea/ui/bubble"
export function BubbleDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Bubble align="end">
<BubbleContent>Hey there! what&apos;s up?</BubbleContent>
</Bubble>
<BubbleGroup>
<Bubble variant="muted">
<BubbleContent>Hey! Want to see chat bubbles?</BubbleContent>
</Bubble>
<Bubble variant="muted">
<BubbleContent>
I can group messages, switch sides, and keep the whole thread easy
to scan.
</BubbleContent>
<BubbleReactions role="img" aria-label="Reaction: thumbs up">
<span>👍</span>
</BubbleReactions>
</Bubble>
</BubbleGroup>
<Bubble align="end">
<BubbleContent>Sure. Hit me with your best demo.</BubbleContent>
</Bubble>
<Bubble variant="muted">
<BubbleContent>
Yes. You are reading a demo that is demoing itself. Very meta. Very
on-brand.
</BubbleContent>
<BubbleReactions
role="img"
aria-label="Reactions: thumbs up, fire, eyes, and 2 more"
>
<span>👍</span>
<span>🔥</span>
<span>👀</span>
<span>+2</span>
</BubbleReactions>
</Bubble>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReactions,
} from "@/styles/base-rhea/ui/bubble"
export function BubbleGroupDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Bubble variant="muted">
<BubbleContent>Can you tell me what&apos;s the issue?</BubbleContent>
</Bubble>
<BubbleGroup>
<Bubble align="end">
<BubbleContent>You tell me!</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>It worked yesterday. You broke it!</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>Find the bug and fix it.</BubbleContent>
<BubbleReactions aria-label="Reactions: eyes" align="start">
<span>👀</span>
</BubbleReactions>
</Bubble>
</BubbleGroup>
<Bubble variant="muted">
<BubbleContent>
Want me to diff yesterday&apos;s you against today&apos;s you?
It&apos;s a bit embarrassing.
</BubbleContent>
</Bubble>
</div>
)
}

View File

@@ -0,0 +1,54 @@
"use client"
import { toast } from "sonner"
import {
Bubble,
BubbleContent,
BubbleGroup,
} from "@/styles/base-rhea/ui/bubble"
export function BubbleLinkButtonDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Bubble variant="muted">
<BubbleContent>How can I help you today?</BubbleContent>
</Bubble>
<BubbleGroup>
<Bubble variant="tinted" align="end">
<BubbleContent
render={
<button onClick={() => toast("You clicked forgot password")} />
}
>
I forgot my password
</BubbleContent>
</Bubble>
<Bubble variant="tinted" align="end">
<BubbleContent
render={
<button
onClick={() => toast("You clicked help with subscription")}
/>
}
>
I need help with my subscription
</BubbleContent>
</Bubble>
<Bubble variant="tinted" align="end">
<BubbleContent
render={
<button
onClick={() =>
toast("You clicked something else. Talk to a human.")
}
/>
}
>
Something else. Talk to a human.
</BubbleContent>
</Bubble>
</BubbleGroup>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { Markdown } from "@/components/markdown"
import { Bubble, BubbleContent } from "@/styles/base-rhea/ui/bubble"
export function BubbleMarkdownDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Bubble align="end" variant="muted">
<BubbleContent>
<Markdown>{`Hello! Are you actually **thinking**?`}</Markdown>
</BubbleContent>
</Bubble>
<Bubble variant="ghost">
<BubbleContent>
<Markdown>{`Ghost bubbles work for assistant text, **markdown**, and other content that should not be framed.
This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render \`code\` in it.
Ghost bubbles are full width and can take the full width of the container.
`}</Markdown>
</BubbleContent>
</Bubble>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { InfoIcon } from "lucide-react"
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/styles/base-rhea/ui/bubble"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/styles/base-rhea/ui/popover"
export function BubblePopoverDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-4 py-12">
<Bubble align="end">
<BubbleContent>Run the build script.</BubbleContent>
</Bubble>
<Bubble variant="destructive">
<BubbleContent>Failed to run the command.</BubbleContent>
<BubbleReactions>
<Popover>
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon-xs"
aria-label="Show error details"
className="aria-expanded:text-destructive"
/>
}
>
<InfoIcon />
</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle className="text-sm">
Command failed with exit code 1
</PopoverTitle>
<PopoverDescription className="text-sm">
ENOENT: no such file or directory, open pnpm-lock.yaml
</PopoverDescription>
</PopoverHeader>
</PopoverContent>
</Popover>
</BubbleReactions>
</Bubble>
</div>
)
}

View File

@@ -0,0 +1,70 @@
"use client"
import { toast } from "sonner"
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/styles/base-rhea/ui/bubble"
import { Button } from "@/styles/base-rhea/ui/button"
export function BubbleReactionsDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-12 py-12">
<Bubble variant="muted" align="end">
<BubbleContent>
I don&apos;t need tests, I know my code works.
</BubbleContent>
<BubbleReactions
align="start"
role="img"
aria-label="Reactions: thumbs up, surprised"
>
<span>👍</span>
<span>😮</span>
</BubbleReactions>
</Bubble>
<Bubble variant="muted">
<BubbleContent>
Bold. Fine I&apos;ll add some tests. I&apos;ll let you know when
they&apos;re done.
</BubbleContent>
<BubbleReactions
role="img"
aria-label="Reactions: eyes, rocket, and 2 more"
>
<span>👀</span>
<span>🚀</span>
<span>+2</span>
</BubbleReactions>
</Bubble>
<Bubble variant="default" align="end">
<BubbleContent>
Tests passed on the first try. All 142 of them. Looking good!
</BubbleContent>
<BubbleReactions
side="top"
align="start"
role="img"
aria-label="Reactions: party popper, clapping hands"
>
<span>🎉</span>
<span>👏</span>
</BubbleReactions>
</Bubble>
<Bubble variant="destructive">
<BubbleContent>Are you sure I can run this command?</BubbleContent>
<BubbleReactions>
<Button
variant="ghost"
size="xs"
onClick={() => toast.success("You clicked yes, running command...")}
>
Yes, run it
</Button>
</BubbleReactions>
</Bubble>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { CheckIcon } from "lucide-react"
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/styles/base-rhea/ui/bubble"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/base-rhea/ui/tooltip"
export function BubbleTooltipDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-4 py-12">
<Bubble variant="secondary">
<BubbleContent>Did you remove the stale route?</BubbleContent>
</Bubble>
<Bubble align="end">
<BubbleContent>Yes, removed it from the registry.</BubbleContent>
<BubbleReactions>
<Tooltip>
<TooltipTrigger render={<Button variant="ghost" size="icon-xs" />}>
<CheckIcon />
</TooltipTrigger>
<TooltipContent>Read on Jan 5, 2026 at 4:32 PM</TooltipContent>
</Tooltip>
</BubbleReactions>
</Bubble>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { Markdown } from "@/components/markdown"
import {
Bubble,
BubbleContent,
BubbleReactions,
} from "@/styles/base-rhea/ui/bubble"
export function BubbleVariantsDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-12 py-12">
<Bubble>
<BubbleContent>This is the default primary bubble.</BubbleContent>
</Bubble>
<Bubble variant="secondary" align="end">
<BubbleContent>This is the secondary variant.</BubbleContent>
</Bubble>
<Bubble variant="muted">
<BubbleContent>
This one is muted. It uses a lower emphasis color for the chat bubble.
</BubbleContent>
<BubbleReactions role="img" aria-label="Reaction: thumbs up">
<span>👍</span>
</BubbleReactions>
</Bubble>
<Bubble variant="tinted" align="end">
<BubbleContent>
This one is tinted. The tint is a softer color derived from the
primary color.
</BubbleContent>
</Bubble>
<Bubble variant="outline">
<BubbleContent>We can also use an outlined variant.</BubbleContent>
</Bubble>
<Bubble variant="destructive" align="end">
<BubbleContent>Or a destructive variant with a reaction.</BubbleContent>
<BubbleReactions role="img" aria-label="Reaction: fire">
<span>🔥</span>
</BubbleReactions>
</Bubble>
<Bubble variant="ghost">
<BubbleContent>
<Markdown>{`Ghost bubbles work for assistant text, **markdown**, and other content that should not be framed.
This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render \`code\` in it.
Ghost bubbles are full width and can take the full width of the container.
`}</Markdown>
</BubbleContent>
</Bubble>
</div>
)
}

View File

@@ -107,7 +107,7 @@ function Calendar({
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", : "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label defaultClassNames.caption_label
), ),
table: "w-full border-collapse", month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none", "flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none",

View File

@@ -0,0 +1,47 @@
import { Button } from "@/styles/base-nova/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-nova/ui/card"
export function CardEdgeToEdge() {
return (
<Card className="mx-auto w-full max-w-sm">
<CardHeader>
<CardTitle>Terms of Service</CardTitle>
<CardDescription>
Review the terms before accepting the agreement.
</CardDescription>
</CardHeader>
<CardContent className="-mb-(--card-spacing)">
<div className="-mx-(--card-spacing) max-h-48 space-y-4 overflow-y-scroll border-t bg-muted/50 px-(--card-spacing) py-4 text-sm leading-relaxed">
<p>
These terms govern your use of the workspace, including access to
shared documents, project files, and collaboration tools.
</p>
<p>
You are responsible for the content you upload and for ensuring that
your team has the appropriate permissions to view or edit it.
</p>
<p>
We may update features or limits as the service evolves. When those
changes materially affect your workflow, we will notify your
workspace administrators.
</p>
<p>
By continuing, you agree to keep your account credentials secure and
to follow your organization&apos;s acceptable use policies.
</p>
</div>
</CardContent>
<CardFooter className="justify-end gap-2">
<Button variant="outline">Decline</Button>
<Button>Accept</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { Button } from "@/styles/base-nova/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-nova/ui/card"
import { Input } from "@/styles/base-nova/ui/input"
import { Label } from "@/styles/base-nova/ui/label"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/styles/base-nova/ui/toggle-group"
const spacingOptions = [
{
className: "[--card-spacing:--spacing(4)]",
label: "16px",
value: "4",
},
{
className: "[--card-spacing:--spacing(5)]",
label: "20px",
value: "5",
},
{
className: "[--card-spacing:--spacing(6)]",
label: "24px",
value: "6",
},
{
className: "[--card-spacing:--spacing(8)]",
label: "32px",
value: "8",
},
]
export function CardSpacing() {
const [spacing, setSpacing] = React.useState("4")
const selectedSpacing = spacingOptions.find(
(option) => option.value === spacing
)
return (
<div className="mx-auto grid w-full max-w-sm gap-4">
<ToggleGroup
value={[spacing]}
onValueChange={(value) => {
if (value[0]) {
setSpacing(value[0])
}
}}
variant="outline"
size="sm"
className="justify-center"
>
{spacingOptions.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
<Card className={selectedSpacing?.className}>
<CardHeader>
<CardTitle>Login to your account</CardTitle>
<CardDescription>
Enter your email below to login to your account
</CardDescription>
<CardAction>
<Button variant="link">Sign Up</Button>
</CardAction>
</CardHeader>
<CardContent>
<form>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="email-spacing">Email</Label>
<Input
id="email-spacing"
type="email"
placeholder="m@example.com"
required
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password-spacing">Password</Label>
<a
href="#"
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input id="password-spacing" type="password" required />
</div>
</div>
</form>
</CardContent>
<CardFooter className="flex-col gap-2">
<Button type="submit" className="w-full">
Login
</Button>
<Button variant="outline" className="w-full">
Login with Google
</Button>
</CardFooter>
</Card>
</div>
)
}

View File

@@ -0,0 +1,43 @@
"use client"
import { Markdown } from "@/components/markdown"
const markdown = `## Getting started
Markdown lets you write formatted text with a simple syntax.
### Features
- **Bold** and *italic* text
- [Links](https://example.com) and \`inline code\`
- Ordered and unordered lists
- Tables, blockquotes, and code blocks
| Syntax | Result |
| --- | --- |
| \`**bold**\` | **bold** |
| \`*italic*\` | *italic* |
| \`\`code\`\` | \`code\` |
How about a quote? How does this look?
> The best way to learn markdown is to write it. Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos.
Now let's try a code block. It should have line numbers, a copy button and syntax highlighting.
\`\`\`tsx
export function Greeting({ name }: { name: string }) {
return <p>Hello, {name}!</p>
}
\`\`\`
If you need more plugins, you can pass them to the \`Markdown\` component.
`
export function MarkdownDemo() {
return (
<div className="max-w-md">
<Markdown>{markdown}</Markdown>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { FileTextIcon, GitBranchIcon, SearchIcon } from "lucide-react"
import { Marker, MarkerContent, MarkerIcon } from "@/styles/base-rhea/ui/marker"
export function MarkerBorderDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-3 py-12">
<Marker variant="border">
<MarkerIcon>
<GitBranchIcon />
</MarkerIcon>
<MarkerContent>Switched to release-candidate</MarkerContent>
</Marker>
<Marker variant="border">
<MarkerIcon>
<SearchIcon />
</MarkerIcon>
<MarkerContent>Reviewed 8 related files</MarkerContent>
</Marker>
<Marker variant="border">
<MarkerIcon>
<FileTextIcon />
</MarkerIcon>
<MarkerContent>Opened implementation notes</MarkerContent>
</Marker>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { GitBranchIcon, SearchIcon } from "lucide-react"
import { Marker, MarkerContent, MarkerIcon } from "@/styles/base-rhea/ui/marker"
import { Spinner } from "@/styles/base-rhea/ui/spinner"
export function MarkerDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Marker>
<MarkerIcon>
<GitBranchIcon />
</MarkerIcon>
<MarkerContent>Switched to a new branch</MarkerContent>
</Marker>
<Marker role="status">
<MarkerIcon>
<Spinner />
</MarkerIcon>
<MarkerContent className="shimmer">Thinking...</MarkerContent>
</Marker>
<Marker variant="separator">
<MarkerContent>Conversation compacted</MarkerContent>
</Marker>
<Marker>
<MarkerIcon>
<SearchIcon />
</MarkerIcon>
<MarkerContent>Explored 4 files</MarkerContent>
</Marker>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { BookOpenCheck, GitBranchIcon, SearchIcon } from "lucide-react"
import { Marker, MarkerContent, MarkerIcon } from "@/styles/base-rhea/ui/marker"
export function MarkerIconDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-12 py-12">
<Marker>
<MarkerIcon>
<GitBranchIcon />
</MarkerIcon>
<MarkerContent>Switched to a new branch</MarkerContent>
</Marker>
<Marker variant="separator">
<MarkerIcon>
<SearchIcon />
</MarkerIcon>
<MarkerContent>Explored 4 files</MarkerContent>
</Marker>
<Marker className="flex-col">
<MarkerIcon>
<BookOpenCheck />
</MarkerIcon>
<MarkerContent>Syncing completed</MarkerContent>
</Marker>
</div>
)
}

View File

@@ -0,0 +1,33 @@
"use client"
import { GitBranchIcon, RotateCcwIcon } from "lucide-react"
import { toast } from "sonner"
import { Marker, MarkerContent, MarkerIcon } from "@/styles/base-rhea/ui/marker"
export function MarkerLinkButtonDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Marker render={<a href="#links-and-buttons" />}>
<MarkerIcon>
<GitBranchIcon />
</MarkerIcon>
<MarkerContent>View the pull request</MarkerContent>
</Marker>
<Marker
render={
<button
type="button"
className="transition-colors hover:text-foreground"
onClick={() => toast("You clicked the revert button")}
/>
}
>
<MarkerIcon>
<RotateCcwIcon />
</MarkerIcon>
<MarkerContent>Revert this change</MarkerContent>
</Marker>
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { Marker, MarkerContent } from "@/styles/base-rhea/ui/marker"
export function MarkerSeparatorDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Marker variant="separator">
<MarkerContent>Today</MarkerContent>
</Marker>
<Marker variant="separator">
<MarkerContent>Worked for 42s</MarkerContent>
</Marker>
<Marker variant="separator">
<MarkerContent>Conversation compacted</MarkerContent>
</Marker>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More