Compare commits

..

77 Commits

Author SHA1 Message Date
shadcn
1d7c378669 fix: position 2026-07-02 14:02:56 +04:00
shadcn
ec132714d2 fix: clean up radix drawers 2026-07-02 12:58:50 +04:00
shadcn
94174c1eab fix: pointer events 2026-07-02 12:46:18 +04:00
shadcn
44c9a8a8ba fix 2026-07-02 12:21:30 +04:00
shadcn
2f8f2be8f9 fix 2026-07-02 11:36:13 +04:00
shadcn
0d07d35d2d fix 2026-07-01 21:08:17 +04:00
shadcn
139ff074bb feat: drawer 2026-07-01 20:56:32 +04:00
shadcn
8d0364a9aa fix: nested 2026-06-30 20:13:01 +04:00
shadcn
7e07ce76f9 wip 2026-06-30 16:56:03 +04:00
shadcn
973ebea834 docs: add migration docs 2026-06-30 12:59:37 +04:00
shadcn
709d0723e6 fix(drawer): clean up base style selectors 2026-06-30 11:15:18 +04:00
shadcn
42e09a31e6 fix(drawer): update base marker example usage 2026-06-30 10:55:37 +04:00
shadcn
d3002bd9d9 chore(drawer): merge main into base drawer migration 2026-06-30 10:44:17 +04:00
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
coi
4b39b1c614 chore: update registries 2026-04-18 03:25:04 +09:00
coi
e224dc30d8 fix(base-drawer): migrate examples, blocks, and app consumers to render and swipeDirection
Update in-repo base drawer consumers to the new wrapper API.

Replace asChild usage with render, switch direction to swipeDirection,
and align examples, blocks, app consumers, and docs with the new
base drawer usage pattern.
2026-04-18 03:23:13 +09:00
coi
2f503b7884 refactor(drawer): move shared drawer direction rules from stylesheets to primitives
Shared drawer styles previously owned direction-specific layout in CSS.
With base and radix now exposing different direction attributes,
keeping that logic in shared tokens would duplicate primitive-specific
branching in the stylesheets.

Move direction-specific layout into the base/radix drawer wrappers and
leave shared CSS responsible only for visual surface styling.

Also move handle visibility and header alignment into TSX, and unify
the shared drawer token names to cn-drawer-*.
2026-04-18 03:19:50 +09:00
coi
07ab679555 refactor(base-drawer): rewrite base drawer wrapper for @base-ui/react/drawer
Replace vaul with @base-ui/react/drawer as the primitive behind the
base drawer wrapper.

Keep the public API aligned with the radix drawer shape while
rewriting DrawerContent to compose Backdrop, Viewport, Popup, and
Content internally.

Keep the registry dependency change in the same commit so the wrapper
rewrite lands as one source-level migration step.
2026-04-18 03:10:23 +09:00
783 changed files with 90169 additions and 26215 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,37 +0,0 @@
import fs from "fs"
const pkgJsonPath = "packages/shadcn/package.json"
const channel = process.argv[2]
const headSha = process.argv[3]
if (!["beta", "rc"].includes(channel)) {
console.error(
`Expected prerelease channel to be "beta" or "rc", got "${channel}".`
)
process.exit(1)
}
if (!headSha) {
console.error("Expected pull request head SHA.")
process.exit(1)
}
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"))
const shortSha = headSha.trim().slice(0, 7)
const baseVersion = channel === "beta" ? "0.0.0" : pkg.version
if (channel === "rc" && baseVersion.includes("-")) {
console.error(
`Expected a stable planned version for rc, got "${baseVersion}".`
)
process.exit(1)
}
pkg.version = `${baseVersion}-${channel}.${shortSha}`
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, "\t") + "\n")
console.log(`Prepared shadcn@${pkg.version}`)
} 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
- name: Build packages
run: pnpm --filter=shadcn build
run: pnpm build:packages
- run: pnpm format:check
@@ -117,6 +117,6 @@ jobs:
run: pnpm install
- name: Build packages
run: pnpm --filter=shadcn build
run: pnpm build:packages
- run: pnpm typecheck

View File

@@ -16,50 +16,64 @@ jobs:
runs-on: ubuntu-latest
name: Write comment to the PR
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
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
const fs = require("fs");
const info = JSON.parse(fs.readFileSync("prerelease-info.json", "utf8"));
for (const artifact of allArtifacts.data.artifacts) {
// Extract the PR number and package version from the artifact name
const match = /^npm-package-shadcn@(.*?)-pr-(\d+)/.exec(artifact.name);
if (match) {
const version = match[1];
const channel = version.includes("-rc.") ? "rc" : "beta";
require("fs").appendFileSync(
process.env.GITHUB_ENV,
`\nPRERELEASE_PACKAGE_VERSION=${version}` +
`\nPRERELEASE_CHANNEL=${channel}` +
`\nPRERELEASE_LABEL=release: ${channel}` +
`\nWORKFLOW_RUN_PR=${match[2]}` +
`\nWORKFLOW_RUN_ID=${context.payload.workflow_run.id}`
);
break;
}
if (!info.packages || info.packages.length === 0) {
core.info("No prerelease packages to comment.");
return;
}
- 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
with:
number: ${{ env.WORKFLOW_RUN_PR }}
message: |
A new ${{ env.PRERELEASE_CHANNEL }} prerelease is available for testing:
number: ${{ steps.info.outputs.pr }}
message: ${{ steps.info.outputs.body }}
```sh
pnpm dlx shadcn@${{ env.PRERELEASE_PACKAGE_VERSION }}
```
View on npm: https://www.npmjs.com/package/shadcn/v/${{ env.PRERELEASE_PACKAGE_VERSION }}
- name: "Remove the prerelease label once published"
- name: Remove the prerelease label once published
if: steps.info.outputs.pr
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -68,8 +82,8 @@ jobs:
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: '${{ env.WORKFLOW_RUN_PR }}',
name: '${{ env.PRERELEASE_LABEL }}',
issue_number: Number("${{ steps.info.outputs.pr }}"),
name: `release: ${{ steps.info.outputs.channel }}`,
});
} catch (error) {
if (error.status !== 404) {

View File

@@ -46,21 +46,8 @@ jobs:
);
}
const selected = selectedLabels[0];
const pullRequest = context.payload.pull_request;
if (
selected.channel === "rc" &&
(pullRequest.head.ref !== "changeset-release/main" ||
pullRequest.title !== "chore(release): version packages")
) {
throw new Error(
"The release: rc label can only be used on the Changesets version PR from changeset-release/main."
);
}
core.setOutput("channel", selected.channel);
core.setOutput("label", selected.name);
core.setOutput("channel", selectedLabels[0].channel);
core.setOutput("label", selectedLabels[0].name);
- name: Checkout Repo
uses: actions/checkout@v4
@@ -86,37 +73,49 @@ jobs:
- name: Install NPM Dependencies
run: pnpm install
- name: Modify package.json version
run: node .github/version-script-prerelease.js ${{ steps.prerelease.outputs.channel }} ${{ github.event.pull_request.head.sha }}
- name: get-npm-version
id: package-version
uses: martinbeentjes/npm-get-version-action@main
with:
path: packages/shadcn
- name: Check package version on NPM
id: package-exists
# A snapshot prerelease needs changesets to compute versions. The
# Changesets version PR consumes them, so a label on that PR is a no-op.
- name: Check for changesets
id: changesets
run: |
if npm view "shadcn@${{ steps.package-version.outputs.current-version }}" version >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
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 Prerelease to NPM
if: ${{ steps.package-exists.outputs.exists == 'false' }}
run: pnpm pub:${{ steps.prerelease.outputs.channel }}
- name: No changesets to prerelease
if: steps.changesets.outputs.present == 'false'
run: echo "::notice::No changesets found on this branch; nothing to prerelease."
- name: Build packaged artifact
if: ${{ steps.package-exists.outputs.exists == 'true' }}
run: pnpm shadcn:build
# Snapshot versions are stamped per run (timestamped), so each publish is
# unique and can never collide with a real release on the latest tag.
- name: Version snapshot
if: steps.changesets.outputs.present == 'true'
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
with:
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
path: packages/shadcn/dist/index.js
name: prerelease-info
path: prerelease-info.json
release:
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
@@ -151,11 +150,10 @@ jobs:
- name: Install NPM Dependencies
run: pnpm install
# - name: Check for errors
# run: pnpm check
- name: Build the package
run: pnpm shadcn:build
# Builds every publishable package under packages/* (shadcn, @shadcn/react),
# never apps/v4, so each dist is fresh before changeset publish.
- name: Build the packages
run: pnpm build:packages
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@v6

View File

@@ -46,3 +46,39 @@ jobs:
run: pnpm install
- 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-cli
shadcn-workspace
# vitest browser mode writes these only on test failure.
__screenshots__
.codex-artifacts
.tmp*
CONTEXT.md
docs/adr

View File

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

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
.content-collections
.source
.devtools

View File

@@ -17,7 +17,6 @@ const chartData = [
{ month: "Feb", amount: 900 },
{ month: "Mar", amount: 1300 },
{ month: "Apr", amount: 750 },
{ month: "May", amount: 1400 },
]
export function ContributionHistory() {
@@ -35,13 +34,14 @@ export function ContributionHistory() {
role="img"
aria-label="Last 6 months of contribution activity"
>
{chartData.map((item) => (
{chartData.map((item, index) => (
<div
key={item.month}
className="flex h-full flex-1 flex-col justify-end gap-2"
>
<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}%` }}
/>
<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 { AnalyticsCard } from "./analytics-card"
import { ClaimableBalance } from "./claimable-balance"
@@ -79,7 +81,7 @@ export function CardsDemo() {
return (
<div
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 />
<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 />
<DividendIncome />
</div>
<div className="hidden flex-col gap-(--gap) 3xl:flex!">
<div className="hidden flex-col gap-(--gap) min-[1400px]:flex">
<NewMilestone />
<PayoutThreshold />
<AccountAccess />
</div>
<div className="hidden flex-col gap-(--gap) md:flex">
<QrConnect />
<TransferFunds />
<div className="**:[.text-center.text-xs]:hidden">
<MessageScrollerDemo />
</div>
{/* <TransferFunds /> */}
<Payments />
</div>
<div className="hidden flex-col gap-(--gap) min-[1400px]:flex">
<div className="hidden flex-col gap-(--gap) min-[1900px]:flex">
<EmptyDistributeTrack />
<AnalyticsCard />
<NotificationSettings />

View File

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

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 { getThemesForBaseColor, STYLES } from "@/registry/config"
import { Button } from "@/styles/base-nova/ui/button"
import {
Card,
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 { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
// Only visible when user clicks "Create Project".
const ProjectForm = dynamic(() =>
import("@/app/(app)/create/components/project-form").then(
(m) => m.ProjectForm
)
// Only visible when user clicks "Create Project". Rendered client-only to
// 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(
(m) => m.ProjectForm
),
{
ssr: false,
loading: () => (
<Button disabled aria-hidden>
Get Code
</Button>
),
}
)
export function Customizer({

View File

@@ -111,11 +111,12 @@ export function OpenPreset({
if (isMobile) {
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>
<Button variant="outline" className={triggerClassName}>
{label}
</Button>
<DrawerTrigger
render={<Button variant="outline" className={triggerClassName} />}
>
{label}
</DrawerTrigger>
<DrawerContent className="dark rounded-t-2xl!">
<DrawerHeader>
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
@@ -127,10 +128,12 @@ export function OpenPreset({
<Button type="submit" className="h-10" disabled={!nextPreset}>
Open
</Button>
<DrawerClose asChild>
<Button variant="outline" type="button" className="h-10">
Cancel
</Button>
<DrawerClose
render={
<Button variant="outline" type="button" className="h-10" />
}
>
Cancel
</DrawerClose>
</DrawerFooter>
</form>

View File

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

View File

@@ -42,7 +42,8 @@ export async function getBaseComponent(name: string, base: BaseName) {
return null
}
return index[name].component
const { Components } = await import("@/registry/bases/__components__")
return Components[base]?.[name] ?? null
}
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="h-(--top-spacing) shrink-0"></div>
{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} />
</div>
) : 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 "@/app/style-registry.css"
import "streamdown/styles.css"
export const revalidate = false
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 "./legacy-themes.css";
@source "../node_modules/streamdown/dist/*.js";
@custom-variant style-vega (&:where(.style-vega *));
@custom-variant style-nova (&:where(.style-nova *));
@custom-variant style-lyra (&:where(.style-lyra *));
@@ -168,6 +170,7 @@
@apply overscroll-y-none;
}
body {
position: relative;
font-synthesis-weight: none;
text-rendering: optimizeLegibility;
}
@@ -284,6 +287,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] {
border-bottom: color-mix(in oklab, var(--border) 30%, transparent);
border-bottom-width: 1px;

View File

@@ -563,3 +563,74 @@
--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

@@ -6,8 +6,8 @@ import { Badge } from "@/registry/new-york-v4/ui/badge"
export function Announcement() {
return (
<Badge asChild variant="secondary" className="bg-muted">
<Link href="/docs/registry/github">
Introducing GitHub Registries <ArrowRightIcon />
<Link href="/docs/changelog">
Components for Chat Interfaces <ArrowRightIcon />
</Link>
</Badge>
)

View File

@@ -6,6 +6,7 @@ import { IconArrowRight } from "@tabler/icons-react"
import { useDocsSearch } from "fumadocs-core/search/client"
import { CornerDownLeftIcon, SquareDashedIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { encodePreset } from "shadcn/preset"
import { type Color, type ColorPalette } from "@/lib/colors"
import { trackEvent } from "@/lib/events"
@@ -36,6 +37,7 @@ import {
} from "@/registry/new-york-v4/ui/dialog"
import { Separator } from "@/registry/new-york-v4/ui/separator"
import { Spinner } from "@/registry/new-york-v4/ui/spinner"
import { STYLES } from "@/registry/styles"
export function CommandMenu({
tree,
@@ -56,7 +58,7 @@ export function CommandMenu({
const [open, setOpen] = React.useState(false)
const [renderDelayedGroups, setRenderDelayedGroups] = React.useState(false)
const [selectedType, setSelectedType] = React.useState<
"color" | "page" | "component" | "block" | null
"color" | "page" | "component" | "block" | "style" | null
>(null)
const [copyPayload, setCopyPayload] = React.useState("")
@@ -208,6 +210,40 @@ export function CommandMenu({
)
}, [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(() => {
return tree.children.map((group) => {
if (group.type !== "folder") {
@@ -425,6 +461,7 @@ export function CommandMenu({
{query.isLoading ? "Searching..." : "No results found."}
</CommandEmpty>
{navItemsSection}
{stylesSection}
{renderDelayedGroups ? (
<>
{pageGroupsSection}
@@ -448,6 +485,7 @@ export function CommandMenu({
? "Go to Page"
: null}
{selectedType === "color" ? "Copy OKLCH" : null}
{selectedType === "style" ? "Open in shadcn/create" : null}
</div>
{copyPayload && (
<>

View File

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

View File

@@ -185,9 +185,7 @@ export function DirectoryAddProvider({
</DrawerHeader>
<div className="px-4">{Content}</div>
<DrawerFooter>
<DrawerClose asChild>
<Button size="sm">Done</Button>
</DrawerClose>
<DrawerClose render={<Button size="sm" />}>Done</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>

View File

@@ -6,10 +6,12 @@ import { BASES } from "@/registry/bases"
export function DocsBaseSwitcher({
base,
component,
hrefPrefix = "/docs/components",
className,
}: {
base: string
component: string
hrefPrefix?: string
className?: string
}) {
const activeBase = BASES.find((baseItem) => base === baseItem.name)
@@ -19,7 +21,7 @@ export function DocsBaseSwitcher({
{BASES.map((baseItem) => (
<Link
key={baseItem.name}
href={`/docs/components/${baseItem.name}/${component}`}
href={`${hrefPrefix}/${baseItem.name}/${component}`}
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"
>

View File

@@ -73,14 +73,12 @@ export function DocsSidebar({
return (
<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"
{...props}
>
<div className="h-9" />
<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" />
<SidebarContent className="no-scrollbar w-(--sidebar-menu-width) overflow-x-hidden px-2.5">
<SidebarGroup className="pt-6">
<SidebarContent className="w-(--sidebar-menu-width) scroll-fade scrollbar-none overflow-x-hidden pl-2.5">
<SidebarGroup className="pt-12">
<SidebarGroupLabel className="font-medium text-muted-foreground">
Sections
</SidebarGroupLabel>
@@ -167,7 +165,6 @@ export function DocsSidebar({
</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>
</Sidebar>
)

View File

@@ -107,7 +107,7 @@ export function DocsTableOfContents({
return (
<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
</p>
{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

@@ -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.
- [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.
- [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,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,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

@@ -4,14 +4,11 @@ description: A drawer component for React.
base: base
component: true
links:
doc: https://vaul.emilkowal.ski/getting-started
doc: https://base-ui.com/react/components/drawer
api: https://base-ui.com/react/components/drawer#api-reference
---
<ComponentPreview styleName="base-nova" name="drawer-demo" />
## About
Drawer is built on top of [Vaul](https://github.com/emilkowalski/vaul) by [emilkowalski](https://twitter.com/emilkowalski).
<ComponentPreview styleName="base-rhea" name="drawer-demo" />
## Installation
@@ -36,7 +33,7 @@ npx shadcn@latest add drawer
<Step>Install the following dependencies:</Step>
```bash
npm install vaul
npm install @base-ui/react
```
<Step>Copy and paste the following code into your project.</Step>
@@ -44,7 +41,7 @@ npm install vaul
<ComponentSource
name="drawer"
title="components/ui/drawer.tsx"
styleName="base-nova"
styleName="base-rhea"
/>
<Step>Update the import paths to match your project setup.</Step>
@@ -55,6 +52,14 @@ npm install vaul
</CodeTabs>
Add the following to your global styles. On iOS Safari, the drawer overlay is absolutely positioned and requires a positioned `body` to cover the viewport after the page is scrolled. See the [Base UI docs](https://base-ui.com/react/overview/quick-start#ios-26-safari) for details.
```css
body {
position: relative;
}
```
## Usage
```tsx showLineNumbers
@@ -72,17 +77,16 @@ import {
```tsx showLineNumbers
<Drawer>
<DrawerTrigger>Open</DrawerTrigger>
<DrawerTrigger render={<Button variant="outline" />}>Open</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Are you absolutely sure?</DrawerTitle>
<DrawerDescription>This action cannot be undone.</DrawerDescription>
</DrawerHeader>
<div className="p-4">{/* Content here */}</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose>
<Button variant="outline">Cancel</Button>
</DrawerClose>
<DrawerClose render={<Button variant="outline" />}>Cancel</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
@@ -102,32 +106,215 @@ Drawer
└── DrawerFooter
```
`DrawerContent` composes the portal, overlay, viewport, and popup from Base UI. For lower-level control, `DrawerPortal`, `DrawerOverlay`, and `DrawerSwipeHandle` are also exported.
## Custom Sizes
A vertical drawer sizes itself to its content and is capped at `calc(100dvh - 6rem)` by default. A side drawer spans `75%` of the viewport width, or `24rem` on larger screens.
To customize the height of a vertical drawer, use the `h-*` and `max-h-*` utilities on `DrawerContent`.
```tsx
<DrawerContent className="h-[50vh]">
```
To customize the width of a side drawer, use the `w-*` and `max-w-*` utilities on `DrawerContent`.
```tsx
<DrawerContent className="w-96">
```
When the same component renders in multiple directions, scope an override to one axis using the `data-[swipe-axis=*]` variants.
```tsx
<DrawerContent className="data-[swipe-axis=y]:max-h-[50vh] data-[swipe-axis=x]:w-96">
```
To make a region of the drawer scrollable, make the scroll container a flex item. Avoid `h-full`, which does not resolve inside a content-sized drawer.
```tsx
<DrawerContent>
<DrawerHeader>...</DrawerHeader>
<div className="flex-1 overflow-y-auto p-4">{/* Scrollable content */}</div>
<DrawerFooter>...</DrawerFooter>
</DrawerContent>
```
## Styling
The drawer exposes CSS variables for style-level customization. Set the sizing variables on `DrawerContent`. Set the overlay variable on `[data-slot=drawer-overlay]` in your CSS.
| Variable | Default | Description |
| ------------------------------ | ---------------------- | ----------------------------------------------------------------------- |
| `--drawer-inset` | `0px` | Floats the drawer from the viewport edges. |
| `--drawer-bleed-background` | `var(--color-popover)` | Fills the gap behind the drawer on swipe overshoot. |
| `--drawer-overlay-min-opacity` | `0` | Minimum overlay opacity. Defaults to `0.5` when snap points are active. |
The drawer also sets data attributes you can target with variants such as `data-[swipe-direction=down]:` on `DrawerContent`, or `group-data-[swipe-axis=y]/drawer-popup:` on its descendants.
| Attribute | Values | Set when |
| ------------------------- | ----------------------------- | ------------------------------------- |
| `data-swipe-direction` | `up`, `right`, `down`, `left` | Always. |
| `data-swipe-axis` | `x`, `y` | Always. |
| `data-snap-points` | Present | The drawer has snap points. |
| `data-expanded` | Present | The drawer is at the full snap point. |
| `data-swiping` | Present | A swipe is in progress. |
| `data-nested-drawer-open` | Present | A nested drawer is open on top. |
## Examples
### Scrollable Content
### Position
Keep actions visible while the content scrolls.
Use the `swipeDirection` prop to set the side of the drawer.
<ComponentPreview styleName="base-nova" name="drawer-scrollable-content" />
Available options are `up`, `right`, `down`, and `left`.
### Sides
<ComponentPreview styleName="base-rhea" name="drawer-sides" />
Use the `direction` prop to set the side of the drawer. Available options are `top`, `right`, `bottom`, and `left`.
### Swipe Handle
<ComponentPreview styleName="base-nova" name="drawer-sides" />
Use `showSwipeHandle` on `Drawer` to render a swipe handle.
### Responsive Dialog
<ComponentPreview styleName="base-rhea" name="drawer-swipe-handle" />
### Nested
Open drawers from inside another drawer. Parent drawers stay mounted and stack behind the frontmost drawer.
<ComponentPreview styleName="base-rhea" name="drawer-nested" />
### Non Modal
Set `modal={false}` to allow interaction with the rest of the page while the drawer is open. Combine with `disablePointerDismissal` to prevent the drawer from closing on outside presses. Use `modal="trap-focus"` to keep focus inside the drawer while leaving scroll and pointer interaction unrestricted.
<ComponentPreview styleName="base-rhea" name="drawer-non-modal" />
### Snap Points
Use `snapPoints` to snap a drawer to preset heights. Numbers between `0` and `1` represent fractions of the viewport. Numbers greater than `1` are treated as pixel values. String values support `px` and `rem` units. Snap points apply to vertical drawers.
Track the active snap point with the controlled `snapPoint` and `onSnapPointChange` props. At the full snap point, the drawer gets a `data-expanded` attribute you can style with the `data-expanded:` variant.
<ComponentPreview styleName="base-rhea" name="drawer-snap-points" />
### Responsive
You can combine the `Dialog` and `Drawer` components to create a responsive dialog. This renders a `Dialog` component on desktop and a `Drawer` on mobile.
<ComponentPreview styleName="base-nova" name="drawer-dialog" />
<ComponentPreview styleName="base-rhea" name="drawer-dialog" />
## RTL
## Migrating from Vaul
To enable RTL support in shadcn/ui, see the [RTL configuration guide](/docs/rtl).
The base drawer now uses [Base UI](https://base-ui.com/react/components/drawer)
instead of Vaul. If you installed the previous base drawer, update your usage
to the Base UI API.
<ComponentPreview styleName="base-nova" name="drawer-rtl" direction="rtl" />
<Steps>
<Step>Update the dependency.</Step>
```diff
- npm install vaul
+ npm install @base-ui/react
```
<Step>Replace `direction` with `swipeDirection`.</Step>
Use `down` instead of `bottom`, and `up` instead of `top`. `left` and `right`
stay the same.
```diff
- <Drawer direction="bottom">
+ <Drawer swipeDirection="down">
```
<Step>Replace `asChild` with `render`.</Step>
For `DrawerTrigger`, pass the trigger element to the `render` prop.
```diff
- <DrawerTrigger asChild>
- <Button variant="outline">Open</Button>
- </DrawerTrigger>
+ <DrawerTrigger render={<Button variant="outline" />}>
+ Open
+ </DrawerTrigger>
```
For `DrawerClose`, pass the close element to the `render` prop.
```diff
- <DrawerClose asChild>
- <Button variant="outline">Cancel</Button>
- </DrawerClose>
+ <DrawerClose render={<Button variant="outline" />}>
+ Cancel
+ </DrawerClose>
```
<Step>Update snap point props.</Step>
If you use snap points, rename the controlled snap point props and the sequential
snap point prop.
```diff
<Drawer
snapPoints={[0.25, 0.5, 1]}
- activeSnapPoint={snapPoint}
- setActiveSnapPoint={setSnapPoint}
- snapToSequentialPoint
+ snapPoint={snapPoint}
+ onSnapPointChange={setSnapPoint}
+ snapToSequentialPoints
>
```
<Step>Update animation and focus props.</Step>
```diff
- <Drawer onAnimationEnd={(open) => setDone(open)}>
+ <Drawer onOpenChangeComplete={(open) => setDone(open)}>
```
```diff
- <DrawerContent onOpenAutoFocus={(event) => event.preventDefault()}>
+ <DrawerContent initialFocus={false}>
```
<Step>Review Vaul-only props.</Step>
Vaul props like `handleOnly`, `repositionInputs`, and
`shouldScaleBackground` do not have one-to-one replacements in the base drawer
API. Use Base UI props such as `disablePointerDismissal`, `modal`, `snapPoints`,
or controlled `open` state for the behavior you need.
```diff
- <Drawer handleOnly repositionInputs={false} shouldScaleBackground>
+ <Drawer>
```
```diff
- <Drawer dismissible={false}>
+ <Drawer disablePointerDismissal>
```
<Step>Update custom data attribute selectors.</Step>
Replace Vaul's `data-vaul-drawer-direction` selectors with Base UI's
`data-swipe-direction` selectors.
```diff
- <DrawerContent className="data-[vaul-drawer-direction=bottom]:max-h-[50vh]">
+ <DrawerContent className="data-[swipe-direction=down]:max-h-[50vh]">
```
Base UI also exposes attributes like `data-swiping`, `data-starting-style`, and
`data-ending-style` for swipe and transition states. Descendants inside
`DrawerContent` can use `group-data-[swipe-axis=x]/drawer-popup` and
`group-data-[swipe-axis=y]/drawer-popup` for axis-specific styling.
</Steps>
## API Reference
See the [Vaul documentation](https://vaul.emilkowal.ski/getting-started) for the full API reference.
See the [Base UI documentation](https://base-ui.com/react/components/drawer) for the full API reference.

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-dialog",
"aspect-ratio",
"attachment",
"avatar",
"badge",
"breadcrumb",
"bubble",
"button",
"button-group",
"calendar",
@@ -35,7 +37,10 @@
"item",
"kbd",
"label",
"marker",
"menubar",
"message",
"message-scroller",
"native-select",
"navigation-menu",
"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.
---
## New Components
<ComponentsList variant="new" />
## All Components
<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

@@ -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-dialog",
"aspect-ratio",
"attachment",
"avatar",
"badge",
"breadcrumb",
"bubble",
"button",
"button-group",
"calendar",
@@ -35,7 +37,10 @@
"item",
"kbd",
"label",
"marker",
"menubar",
"message",
"message-scroller",
"native-select",
"navigation-menu",
"pagination",

View File

@@ -4,10 +4,12 @@
"components",
"(root)",
"changelog",
"react",
"forms",
"installation",
"dark-mode",
"rtl",
"utils",
"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

@@ -10,6 +10,7 @@
"authentication",
"mcp",
"open-in-v0",
"api-reference",
"registry-json",
"registry-item-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",
".source/**",
"**/__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",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none",

View File

@@ -1,10 +1,11 @@
"use client"
import * as React from "react"
import { Minus, Plus } from "lucide-react"
import { Bar, BarChart, ResponsiveContainer } from "recharts"
import { toast } from "sonner"
import { Button } from "@/styles/base-nova/ui/button"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/styles/base-rhea/ui/badge"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Drawer,
DrawerClose,
@@ -14,121 +15,117 @@ import {
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-nova/ui/drawer"
} from "@/styles/base-rhea/ui/drawer"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldTitle,
} from "@/styles/base-rhea/ui/field"
import { RadioGroup, RadioGroupItem } from "@/styles/base-rhea/ui/radio-group"
const data = [
const deliveryTimes = [
{
goal: 400,
value: "asap",
id: "delivery-asap",
label: "Standard delivery",
description: "2535 min · Driver assigned now",
badge: "Fastest",
},
{
goal: 300,
value: "5-00",
id: "delivery-5-00",
label: "5:00 PM 5:15 PM",
description: "Prep starts at 4:45 PM",
},
{
goal: 200,
value: "5-30",
id: "delivery-5-30",
label: "5:30 PM 5:45 PM",
description: "Good if you're heading home",
},
{
goal: 300,
value: "6-00",
id: "delivery-6-00",
label: "6:00 PM 6:15 PM",
description: "Most popular · High demand",
},
{
goal: 200,
},
{
goal: 278,
},
{
goal: 189,
},
{
goal: 239,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 278,
},
{
goal: 189,
},
{
goal: 349,
value: "6-30",
id: "delivery-6-30",
label: "6:30 PM 6:45 PM",
description: "Last slot before kitchen closes",
},
]
export function DrawerDemo() {
const [goal, setGoal] = React.useState(350)
const [open, setOpen] = React.useState(false)
const [deliveryTime, setDeliveryTime] = React.useState("asap")
const isMobile = useIsMobile()
function onClick(adjustment: number) {
setGoal(Math.max(200, Math.min(400, goal + adjustment)))
function handleConfirm() {
const selected = deliveryTimes.find((time) => time.value === deliveryTime)
if (!selected) {
return
}
setOpen(false)
toast("Delivery time confirmed", {
description: selected.label,
})
}
return (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Open Drawer</Button>
<Drawer
open={open}
onOpenChange={setOpen}
showSwipeHandle={isMobile}
swipeDirection={isMobile ? "down" : "right"}
>
<DrawerTrigger render={<Button variant="secondary" />}>
Open Drawer
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Move Goal</DrawerTitle>
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
</DrawerHeader>
<div className="p-4 pb-0">
<div className="flex items-center justify-center space-x-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 rounded-full"
onClick={() => onClick(-10)}
disabled={goal <= 200}
>
<Minus />
<span className="sr-only">Decrease</span>
</Button>
<div className="flex-1 text-center">
<div className="text-7xl font-bold tracking-tighter">
{goal}
</div>
<div className="text-[0.70rem] text-muted-foreground uppercase">
Calories/day
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 rounded-full"
onClick={() => onClick(10)}
disabled={goal >= 400}
>
<Plus />
<span className="sr-only">Increase</span>
</Button>
</div>
<div className="mt-3 h-[120px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<Bar
dataKey="goal"
style={
{
fill: "var(--chart-1)",
} as React.CSSProperties
}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
<DrawerHeader>
<DrawerTitle>Pick a delivery time</DrawerTitle>
<DrawerDescription>
We&apos;ll prepare your order as soon as possible.
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 scroll-fade overflow-y-auto p-4">
<RadioGroup
value={deliveryTime}
onValueChange={setDeliveryTime}
className="gap-2"
>
{deliveryTimes.map((time) => (
<FieldLabel key={time.value} htmlFor={time.id}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle className="flex items-center gap-2">
{time.label}
{time.badge ? (
<Badge variant="secondary">{time.badge}</Badge>
) : null}
</FieldTitle>
<FieldDescription>{time.description}</FieldDescription>
</FieldContent>
<RadioGroupItem value={time.value} id={time.id} />
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
<DrawerFooter>
<Button onClick={handleConfirm} className="h-[34px]">
Confirm Delivery Time
</Button>
<DrawerClose render={<Button variant="outline" />}>
Cancel
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import { cn } from "@/lib/utils"
import { useMediaQuery } from "@/hooks/use-media-query"
import { Button } from "@/styles/base-nova/ui/button"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Dialog,
DialogContent,
@@ -12,7 +12,7 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/styles/base-nova/ui/dialog"
} from "@/styles/base-rhea/ui/dialog"
import {
Drawer,
DrawerClose,
@@ -22,9 +22,9 @@ import {
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-nova/ui/drawer"
import { Input } from "@/styles/base-nova/ui/input"
import { Label } from "@/styles/base-nova/ui/label"
} from "@/styles/base-rhea/ui/drawer"
import { Input } from "@/styles/base-rhea/ui/input"
import { Label } from "@/styles/base-rhea/ui/label"
export function DrawerDialogDemo() {
const [open, setOpen] = React.useState(false)
@@ -52,8 +52,8 @@ export function DrawerDialogDemo() {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button variant="outline">Edit Profile</Button>
<DrawerTrigger render={<Button variant="outline" />}>
Edit Profile
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
@@ -62,12 +62,7 @@ export function DrawerDialogDemo() {
Make changes to your profile here. Click save when you&apos;re done.
</DrawerDescription>
</DrawerHeader>
<ProfileForm className="px-4" />
<DrawerFooter className="pt-2">
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
<ProfileForm className="p-4" />
</DrawerContent>
</Drawer>
)

View File

@@ -0,0 +1,111 @@
"use client"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-rhea/ui/drawer"
export function DrawerNested() {
const isMobile = useIsMobile()
const swipeDirection = isMobile ? "down" : "right"
return (
<Drawer showSwipeHandle={isMobile} swipeDirection={swipeDirection}>
<DrawerTrigger render={<Button variant="secondary" />}>
Open Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Drawer</DrawerTitle>
<DrawerDescription>
Open another drawer from the same direction.
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<Drawer showSwipeHandle={isMobile} swipeDirection={swipeDirection}>
<DrawerTrigger render={<Button variant="outline" />}>
Open Nested Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Nested Drawer</DrawerTitle>
<DrawerDescription>
The parent drawer stays mounted behind this one.
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<Drawer
showSwipeHandle={isMobile}
swipeDirection={swipeDirection}
>
<DrawerTrigger render={<Button variant="outline" />}>
Open Third Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Third Drawer</DrawerTitle>
<DrawerDescription>
Two drawers are stacked behind this one.
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<Drawer
showSwipeHandle={isMobile}
swipeDirection={swipeDirection}
>
<DrawerTrigger render={<Button variant="outline" />}>
Open Fourth Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Fourth Drawer</DrawerTitle>
<DrawerDescription>
This is the frontmost drawer in the stack.
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" />}>
Close
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DrawerClose render={<Button variant="outline" />}>
Close
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DrawerClose render={<Button variant="outline" />}>
Close
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DrawerClose render={<Button variant="outline" />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

View File

@@ -0,0 +1,31 @@
import { Button } from "@/styles/base-rhea/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-rhea/ui/drawer"
export function DrawerNonModal() {
return (
<Drawer modal={false} disablePointerDismissal swipeDirection="right">
<DrawerTrigger render={<Button variant="outline" />}>
Non Modal
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Non Modal Drawer</DrawerTitle>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-80 group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<DrawerClose render={<Button />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

View File

@@ -1,13 +1,14 @@
"use client"
import * as React from "react"
import { Minus, Plus } from "lucide-react"
import { Bar, BarChart, ResponsiveContainer, XAxis } from "recharts"
import { toast } from "sonner"
import { useIsMobile } from "@/hooks/use-mobile"
import {
useTranslation,
type Translations,
} from "@/components/language-selector"
import { Badge } from "@/styles/base-nova/ui-rtl/badge"
import { Button } from "@/styles/base-nova/ui-rtl/button"
import {
Drawer,
@@ -19,62 +20,40 @@ import {
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-nova/ui-rtl/drawer"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldTitle,
} from "@/styles/base-nova/ui-rtl/field"
import {
RadioGroup,
RadioGroupItem,
} from "@/styles/base-nova/ui-rtl/radio-group"
const data = [
{
goal: 400,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 278,
},
{
goal: 189,
},
{
goal: 239,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 278,
},
{
goal: 189,
},
{
goal: 349,
},
]
const translations: Translations = {
const translations = {
en: {
dir: "ltr",
locale: "en-US",
values: {
trigger: "Open Drawer",
title: "Move Goal",
description: "Set your daily activity goal.",
caloriesPerDay: "Calories/day",
decrease: "Decrease",
increase: "Increase",
submit: "Submit",
title: "Pick a delivery time",
description: "We'll prepare your order as soon as possible.",
confirm: "Confirm Delivery Time",
cancel: "Cancel",
toastTitle: "Delivery time confirmed",
asapLabel: "Standard delivery",
asapDescription: "2535 min · Driver assigned now",
asapBadge: "Fastest",
slot500Label: "5:00 PM 5:15 PM",
slot500Description: "Prep starts at 4:45 PM",
slot530Label: "5:30 PM 5:45 PM",
slot530Description: "Good if you're heading home",
slot600Label: "6:00 PM 6:15 PM",
slot600Description: "Most popular · High demand",
slot630Label: "6:30 PM 6:45 PM",
slot630Description: "Last slot before kitchen closes",
},
},
ar: {
@@ -82,13 +61,22 @@ const translations: Translations = {
locale: "ar-EG",
values: {
trigger: "فتح الدرج",
title: "نقل الهدف",
description: "حدد هدف نشاطك اليومي.",
caloriesPerDay: "سعرات حرارية/يوم",
decrease: "تقليل",
increase: "زيادة",
submit: "إرسال",
title: "اختر وقت التوصيل",
description: "سنجهز طلبك في أقرب وقت ممكن.",
confirm: "تأكيد وقت التوصيل",
cancel: "إلغاء",
toastTitle: "تم تأكيد وقت التوصيل",
asapLabel: "توصيل قياسي",
asapDescription: "2535 دقيقة · تم تعيين السائق الآن",
asapBadge: "الأسرع",
slot500Label: "5:00 م 5:15 م",
slot500Description: "يبدأ التحضير في 4:45 م",
slot530Label: "5:30 م 5:45 م",
slot530Description: "مناسب إذا كنت في الطريق إلى المنزل",
slot600Label: "6:00 م 6:15 م",
slot600Description: "الأكثر شيوعًا · طلب مرتفع",
slot630Label: "6:30 م 6:45 م",
slot630Description: "آخر موعد قبل إغلاق المطبخ",
},
},
he: {
@@ -96,97 +84,137 @@ const translations: Translations = {
locale: "he-IL",
values: {
trigger: "פתח מגירה",
title: "הזז מטרה",
description: "הגדר את יעד הפעילות היומי שלך.",
caloriesPerDay: "קלוריות/יום",
decrease: "הקטן",
increase: "הגדל",
submit: "שלח",
title: "בחר זמן משלוח",
description: "נכין את ההזמנה שלך בהקדם האפשרי.",
confirm: "אשר זמן משלוח",
cancel: "בטל",
toastTitle: "זמן המשלוח אושר",
asapLabel: "משלוח רגיל",
asapDescription: "2535 דק׳ · נהג הוקצה כעת",
asapBadge: "הכי מהיר",
slot500Label: "17:00 17:15",
slot500Description: "ההכנה מתחילה ב-16:45",
slot530Label: "17:30 17:45",
slot530Description: "מתאים אם אתה בדרך הביתה",
slot600Label: "18:00 18:15",
slot600Description: "הפופולרי ביותר · ביקוש גבוה",
slot630Label: "18:30 18:45",
slot630Description: "המשבצת האחרונה לפני סגירת המטבח",
},
},
}
} satisfies Translations
type TranslationKey = keyof typeof translations.en.values
const deliveryTimes: Array<{
value: string
id: string
labelKey: TranslationKey
descriptionKey: TranslationKey
badgeKey?: TranslationKey
}> = [
{
value: "asap",
id: "delivery-asap-rtl",
labelKey: "asapLabel",
descriptionKey: "asapDescription",
badgeKey: "asapBadge",
},
{
value: "5-00",
id: "delivery-5-00-rtl",
labelKey: "slot500Label",
descriptionKey: "slot500Description",
},
{
value: "5-30",
id: "delivery-5-30-rtl",
labelKey: "slot530Label",
descriptionKey: "slot530Description",
},
{
value: "6-00",
id: "delivery-6-00-rtl",
labelKey: "slot600Label",
descriptionKey: "slot600Description",
},
{
value: "6-30",
id: "delivery-6-30-rtl",
labelKey: "slot630Label",
descriptionKey: "slot630Description",
},
]
export function DrawerRtl() {
const { dir, locale, language, t } = useTranslation(translations, "ar")
const [goal, setGoal] = React.useState(350)
const { dir, language, t } = useTranslation(translations, "ar")
const [open, setOpen] = React.useState(false)
const [deliveryTime, setDeliveryTime] = React.useState("asap")
const isMobile = useIsMobile()
function onClick(adjustment: number) {
setGoal(Math.max(200, Math.min(400, goal + adjustment)))
function handleConfirm() {
const selected = deliveryTimes.find((time) => time.value === deliveryTime)
if (!selected) {
return
}
setOpen(false)
toast(t.toastTitle, {
description: t[selected.labelKey],
})
}
return (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">{t.trigger}</Button>
<Drawer
open={open}
onOpenChange={setOpen}
showSwipeHandle={isMobile}
swipeDirection={isMobile ? "down" : "right"}
>
<DrawerTrigger render={<Button variant="secondary" />}>
{t.trigger}
</DrawerTrigger>
<DrawerContent dir={dir} data-lang={dir === "rtl" ? language : undefined}>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>{t.title}</DrawerTitle>
<DrawerDescription>{t.description}</DrawerDescription>
</DrawerHeader>
<div className="p-4 pb-0">
<div className="flex items-center justify-center space-x-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 rounded-full"
onClick={() => onClick(-10)}
disabled={goal <= 200}
>
<Minus />
<span className="sr-only">{t.decrease}</span>
</Button>
<div className="flex-1 text-center">
<div className="text-7xl font-bold tracking-tighter">
{goal.toLocaleString(locale)}
</div>
<div className="text-[0.70rem] text-muted-foreground uppercase">
{t.caloriesPerDay}
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 rounded-full"
onClick={() => onClick(10)}
disabled={goal >= 400}
>
<Plus />
<span className="sr-only">{t.increase}</span>
</Button>
</div>
<div className="mt-3 h-[120px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<XAxis
dataKey="goal"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => value.toLocaleString(locale)}
reversed={dir === "rtl"}
/>
<Bar
dataKey="goal"
style={
{
fill: "var(--chart-2)",
} as React.CSSProperties
}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<DrawerFooter>
<Button>{t.submit}</Button>
<DrawerClose asChild>
<Button variant="outline">{t.cancel}</Button>
</DrawerClose>
</DrawerFooter>
<DrawerHeader>
<DrawerTitle>{t.title}</DrawerTitle>
<DrawerDescription>{t.description}</DrawerDescription>
</DrawerHeader>
<div className="flex-1 scroll-fade overflow-y-auto p-4">
<RadioGroup
value={deliveryTime}
onValueChange={setDeliveryTime}
className="gap-2"
dir={dir}
>
{deliveryTimes.map((time) => (
<FieldLabel key={time.value} htmlFor={time.id} dir={dir}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle className="flex items-center gap-2">
{t[time.labelKey]}
{time.badgeKey ? (
<Badge variant="secondary">{t[time.badgeKey]}</Badge>
) : null}
</FieldTitle>
<FieldDescription dir={dir}>
{t[time.descriptionKey]}
</FieldDescription>
</FieldContent>
<RadioGroupItem value={time.value} id={time.id} dir={dir} />
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
<DrawerFooter>
<Button onClick={handleConfirm} className="h-[34px]">
{t.confirm}
</Button>
<DrawerClose render={<Button variant="outline" />}>
{t.cancel}
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)

View File

@@ -1,46 +0,0 @@
import { Button } from "@/styles/base-nova/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-nova/ui/drawer"
export function DrawerScrollableContent() {
return (
<Drawer direction="right">
<DrawerTrigger asChild>
<Button variant="outline">Scrollable Content</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Move Goal</DrawerTitle>
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
</DrawerHeader>
<div className="no-scrollbar overflow-y-auto px-4">
{Array.from({ length: 10 }).map((_, index) => (
<p key={index} className="mb-4 leading-normal">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
))}
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

View File

@@ -1,4 +1,4 @@
import { Button } from "@/styles/base-nova/ui/button"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Drawer,
DrawerClose,
@@ -8,55 +8,26 @@ import {
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-nova/ui/drawer"
const DRAWER_SIDES = ["top", "right", "bottom", "left"] as const
} from "@/styles/base-rhea/ui/drawer"
export function DrawerWithSides() {
return (
<div className="flex flex-wrap gap-2">
{DRAWER_SIDES.map((side) => (
<Drawer
key={side}
direction={
side === "bottom" ? undefined : (side as "top" | "right" | "left")
}
>
<DrawerTrigger asChild>
<Button variant="outline" className="capitalize">
{side}
</Button>
</DrawerTrigger>
<DrawerContent className="data-[vaul-drawer-direction=bottom]:max-h-[50vh] data-[vaul-drawer-direction=top]:max-h-[50vh]">
<DrawerHeader>
<DrawerTitle>Move Goal</DrawerTitle>
<DrawerDescription>
Set your daily activity goal.
</DrawerDescription>
</DrawerHeader>
<div className="no-scrollbar overflow-y-auto px-4">
{Array.from({ length: 10 }).map((_, index) => (
<p key={index} className="mb-4 leading-normal">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat. Duis aute
irure dolor in reprehenderit in voluptate velit esse cillum
dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt
mollit anim id est laborum.
</p>
))}
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
))}
</div>
<Drawer swipeDirection="left">
<DrawerTrigger render={<Button variant="secondary" />}>
Open Left Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Move Goal</DrawerTitle>
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="size-full rounded-2xl bg-muted" />
</div>
<DrawerFooter>
<DrawerClose render={<Button />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

View File

@@ -0,0 +1,40 @@
"use client"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-rhea/ui/drawer"
const SNAP_POINTS = ["31rem", 1]
export function DrawerSnapPoints() {
return (
<Drawer snapPoints={SNAP_POINTS} showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>
Open Snap Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Snap points</DrawerTitle>
<DrawerDescription>
Drag the drawer to snap between a compact peek and a near
full-height view.
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-80 group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<DrawerClose render={<Button />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

View File

@@ -0,0 +1,35 @@
"use client"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-rhea/ui/drawer"
export function DrawerSwipeHandle() {
return (
<Drawer showSwipeHandle>
<DrawerTrigger render={<Button variant="secondary" />}>
Open Drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Drawer</DrawerTitle>
<DrawerDescription>Drawer with a swipe handle.</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-80 group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<DrawerClose render={<Button />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

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>
)
}

View File

@@ -0,0 +1,14 @@
import { Marker, MarkerContent } from "@/styles/base-rhea/ui/marker"
export function MarkerShimmerDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Marker role="status">
<MarkerContent className="shimmer">Thinking...</MarkerContent>
</Marker>
<Marker variant="separator" role="status">
<MarkerContent className="shimmer">Reading 4 files</MarkerContent>
</Marker>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Marker, MarkerContent, MarkerIcon } from "@/styles/base-rhea/ui/marker"
import { Spinner } from "@/styles/base-rhea/ui/spinner"
export function MarkerStatusDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Marker role="status">
<MarkerIcon>
<Spinner />
</MarkerIcon>
<MarkerContent>Compacting conversation</MarkerContent>
</Marker>
<Marker variant="separator" role="status">
<MarkerIcon>
<Spinner />
</MarkerIcon>
<MarkerContent>Running tests</MarkerContent>
</Marker>
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { Marker, MarkerContent } from "@/styles/base-rhea/ui/marker"
export function MarkerVariantsDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Marker>
<MarkerContent>A default marker for inline notes.</MarkerContent>
</Marker>
<Marker variant="separator">
<MarkerContent>A separator marker</MarkerContent>
</Marker>
<Marker variant="border">
<MarkerContent>A border marker for row boundaries.</MarkerContent>
</Marker>
</div>
)
}

View File

@@ -0,0 +1,64 @@
import {
CopyIcon,
RefreshCcwIcon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "lucide-react"
import { Bubble, BubbleContent } from "@/styles/base-rhea/ui/bubble"
import { Button } from "@/styles/base-rhea/ui/button"
import {
Message,
MessageContent,
MessageFooter,
} from "@/styles/base-rhea/ui/message"
export function MessageActionsDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Message>
<MessageContent>
<Bubble variant="muted">
<BubbleContent>
The install failure is coming from the workspace package.
</BubbleContent>
</Bubble>
<MessageFooter>
<Button variant="ghost" size="icon" aria-label="Copy" title="Copy">
<CopyIcon />
</Button>
<Button variant="ghost" size="icon" aria-label="Like" title="Like">
<ThumbsUpIcon />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Dislike"
title="Dislike"
>
<ThumbsDownIcon />
</Button>
</MessageFooter>
</MessageContent>
</Message>
<Message align="end">
<MessageContent>
<Bubble>
<BubbleContent>Okay drop me a link. Taking a look...</BubbleContent>
</Bubble>
<MessageFooter className="gap-2">
<span className="font-normal text-destructive">Failed to send</span>
<Button
variant="ghost"
size="icon-xs"
title="Retry"
aria-label="Retry"
>
<RefreshCcwIcon />
</Button>
</MessageFooter>
</MessageContent>
</Message>
</div>
)
}

View File

@@ -0,0 +1,76 @@
"use client"
import { DownloadIcon, FileTextIcon } from "lucide-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/styles/base-rhea/ui/attachment"
import { Bubble, BubbleContent } from "@/styles/base-rhea/ui/bubble"
import { Message, MessageContent } from "@/styles/base-rhea/ui/message"
export function MessageAttachmentDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
<Message align="end">
<MessageContent>
<Attachment orientation="vertical">
<AttachmentMedia variant="image">
<img
src="https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80"
alt="Workspace"
/>
</AttachmentMedia>
</Attachment>
<Bubble>
<BubbleContent>
Here&apos;s the image. Can you add it to the PDF? Use it for the
cover page.
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent>
<Bubble variant="muted">
<BubbleContent>
Done. Here&apos;s the PDF with the image added as the cover page.
</BubbleContent>
</Bubble>
<Attachment>
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
type="button"
title="Download"
aria-label="Download"
size="icon-sm"
variant="secondary"
>
<DownloadIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</MessageContent>
</Message>
<Message align="end">
<MessageContent>
<Bubble>
<BubbleContent>Thanks. Looks good.</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/styles/base-rhea/ui/avatar"
import {
Bubble,
BubbleContent,
BubbleGroup,
} from "@/styles/base-rhea/ui/bubble"
import {
Message,
MessageAvatar,
MessageContent,
} from "@/styles/base-rhea/ui/message"
export function MessageAvatarDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-6 py-12">
<Message>
<MessageAvatar>
<Avatar>
<AvatarImage src="/avatars/03.png" alt="@avatar" />
<AvatarFallback>R</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<Bubble variant="muted">
<BubbleContent>
The build failed during dependency installation.
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message align="end">
<MessageAvatar>
<Avatar>
<AvatarImage src="/avatars/10.png" alt="@avatar" />
<AvatarFallback>R</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<Bubble>
<BubbleContent>Can you share the exact error?</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageAvatar>
<Avatar>
<AvatarImage src="/avatars/03.png" alt="@avatar" />
<AvatarFallback>R</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<BubbleGroup>
<Bubble variant="muted">
<BubbleContent>Here&apos;s the error from the logs</BubbleContent>
</Bubble>
<Bubble variant="muted">
<BubbleContent>
Something went wrong with the build. The libraries are not
installed correctly. Try running the build again.
</BubbleContent>
</Bubble>
</BubbleGroup>
</MessageContent>
</Message>
</div>
)
}

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