mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-01 08:34:12 +00:00
Compare commits
86 Commits
shadcn@4.8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbf9c5ebc4 | ||
|
|
897e9add14 | ||
|
|
8d6553a7f5 | ||
|
|
683073f102 | ||
|
|
02e398ab73 | ||
|
|
af79276f7e | ||
|
|
5a3ad36a5e | ||
|
|
a63e8359ec | ||
|
|
a72491cb9b | ||
|
|
67aec7dcc5 | ||
|
|
40c7064532 | ||
|
|
5b3369f6ee | ||
|
|
b31e6d63b0 | ||
|
|
cf5b227565 | ||
|
|
8055a12f46 | ||
|
|
18fcf0f766 | ||
|
|
c520191cd4 | ||
|
|
35983528c2 | ||
|
|
8692cd4cc1 | ||
|
|
d3727d8c45 | ||
|
|
e3f98d49e4 | ||
|
|
549852bffc | ||
|
|
4139cff3a0 | ||
|
|
95471a0fb9 | ||
|
|
b59f68ecc5 | ||
|
|
6956a099b3 | ||
|
|
2417242b12 | ||
|
|
70a7a0c2a8 | ||
|
|
5602b81d83 | ||
|
|
3ffd3e1c7c | ||
|
|
e03df56fcf | ||
|
|
38fb1b6f41 | ||
|
|
13eb6a81d1 | ||
|
|
5cc8a2af42 | ||
|
|
365d53b590 | ||
|
|
c2ddedf5d2 | ||
|
|
c879483c96 | ||
|
|
4885a9a7ad | ||
|
|
2f5929269a | ||
|
|
2fdf1bb0d4 | ||
|
|
951750bdbe | ||
|
|
6c5d5d6374 | ||
|
|
6ec117715f | ||
|
|
e583c773fe | ||
|
|
0f154171a7 | ||
|
|
9197676b3d | ||
|
|
82dce7e945 | ||
|
|
35bc9934bf | ||
|
|
1fd75c9d7e | ||
|
|
2ea31d8070 | ||
|
|
ea9d371a2d | ||
|
|
d15f17d717 | ||
|
|
46ca8c5d4b | ||
|
|
a5eb279650 | ||
|
|
1994caba0b | ||
|
|
1450bea8d6 | ||
|
|
ced2a5beb5 | ||
|
|
10f1717a3e | ||
|
|
deba036d50 | ||
|
|
5bd81beebf | ||
|
|
3f2ff18157 | ||
|
|
05eb2b968b | ||
|
|
a721cc08e5 | ||
|
|
8da4592308 | ||
|
|
f47d48f316 | ||
|
|
e6d9d6023b | ||
|
|
7dfd933102 | ||
|
|
9c6a5ee1b1 | ||
|
|
c87897b2a5 | ||
|
|
c61197f627 | ||
|
|
a1fb619cef | ||
|
|
d84c4a8ca5 | ||
|
|
cd54e0927f | ||
|
|
adac7cae1f | ||
|
|
7c63c46736 | ||
|
|
916c012132 | ||
|
|
460ad60d84 | ||
|
|
8e2d2d1439 | ||
|
|
67cef8fcb9 | ||
|
|
4ff43ba694 | ||
|
|
efdec3ca45 | ||
|
|
5c849297d0 | ||
|
|
2baa86081d | ||
|
|
980f288149 | ||
|
|
07900769d9 | ||
|
|
360e8a19c3 |
35
.github/collect-prerelease-info.js
vendored
Normal file
35
.github/collect-prerelease-info.js
vendored
Normal 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).`)
|
||||||
21
.github/version-script-beta.js
vendored
21
.github/version-script-beta.js
vendored
@@ -1,21 +0,0 @@
|
|||||||
// ORIGINALLY FROM CLOUDFLARE WRANGLER:
|
|
||||||
// https://github.com/cloudflare/wrangler2/blob/main/.github/version-script.js
|
|
||||||
|
|
||||||
import { exec } from "child_process"
|
|
||||||
import fs from "fs"
|
|
||||||
|
|
||||||
const pkgJsonPath = "packages/shadcn/package.json"
|
|
||||||
try {
|
|
||||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath))
|
|
||||||
exec("git rev-parse --short HEAD", (err, stdout) => {
|
|
||||||
if (err) {
|
|
||||||
console.log(err)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
pkg.version = "0.0.0-beta." + stdout.trim()
|
|
||||||
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, "\t") + "\n")
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
56
.github/workflows/browser-tests.yml
vendored
Normal file
56
.github/workflows/browser-tests.yml
vendored
Normal 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
|
||||||
4
.github/workflows/code-check.yml
vendored
4
.github/workflows/code-check.yml
vendored
@@ -78,7 +78,7 @@ jobs:
|
|||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Build packages
|
- name: Build packages
|
||||||
run: pnpm --filter=shadcn build
|
run: pnpm build:packages
|
||||||
|
|
||||||
- run: pnpm format:check
|
- run: pnpm format:check
|
||||||
|
|
||||||
@@ -117,6 +117,6 @@ jobs:
|
|||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Build packages
|
- name: Build packages
|
||||||
run: pnpm --filter=shadcn build
|
run: pnpm build:packages
|
||||||
|
|
||||||
- run: pnpm typecheck
|
- run: pnpm typecheck
|
||||||
|
|||||||
94
.github/workflows/prerelease-comment.yml
vendored
94
.github/workflows/prerelease-comment.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
# Adapted from create-t3-app.
|
# Adapted from create-t3-app.
|
||||||
name: Write Beta Release comment
|
name: Write Prerelease comment
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
@@ -16,51 +16,79 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Write comment to the PR
|
name: Write comment to the PR
|
||||||
steps:
|
steps:
|
||||||
- name: "Comment on PR"
|
# Stable pushes and no-changeset runs upload no artifact, so a missing
|
||||||
|
# download is expected — gate the rest of the job on it succeeding.
|
||||||
|
- name: Download prerelease info
|
||||||
|
id: download
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: prerelease-info
|
||||||
|
run-id: ${{ github.event.workflow_run.id }}
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build comment
|
||||||
|
id: info
|
||||||
|
if: steps.download.outcome == 'success'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
script: |
|
script: |
|
||||||
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
const fs = require("fs");
|
||||||
owner: context.repo.owner,
|
const info = JSON.parse(fs.readFileSync("prerelease-info.json", "utf8"));
|
||||||
repo: context.repo.repo,
|
|
||||||
run_id: context.payload.workflow_run.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const artifact of allArtifacts.data.artifacts) {
|
if (!info.packages || info.packages.length === 0) {
|
||||||
// Extract the PR number and package version from the artifact name
|
core.info("No prerelease packages to comment.");
|
||||||
const match = /^npm-package-shadcn@(.*?)-pr-(\d+)/.exec(artifact.name);
|
return;
|
||||||
|
|
||||||
if (match) {
|
|
||||||
require("fs").appendFileSync(
|
|
||||||
process.env.GITHUB_ENV,
|
|
||||||
`\nBETA_PACKAGE_VERSION=${match[1]}` +
|
|
||||||
`\nWORKFLOW_RUN_PR=${match[2]}` +
|
|
||||||
`\nWORKFLOW_RUN_ID=${context.payload.workflow_run.id}`
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: "Comment on PR with Link"
|
const installs = info.packages
|
||||||
|
.map((p) => `pnpm dlx ${p.name}@${p.version}`)
|
||||||
|
.join("\n");
|
||||||
|
const links = info.packages
|
||||||
|
.map(
|
||||||
|
(p) =>
|
||||||
|
`- [${p.name}@${p.version}](https://www.npmjs.com/package/${p.name}/v/${p.version})`
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
`A new ${info.channel} prerelease is available for testing:`,
|
||||||
|
"",
|
||||||
|
"```sh",
|
||||||
|
installs,
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
links,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
core.setOutput("pr", info.pr);
|
||||||
|
core.setOutput("channel", info.channel);
|
||||||
|
core.setOutput("body", body);
|
||||||
|
|
||||||
|
- name: Comment on PR
|
||||||
|
if: steps.info.outputs.body
|
||||||
uses: marocchino/sticky-pull-request-comment@v2
|
uses: marocchino/sticky-pull-request-comment@v2
|
||||||
with:
|
with:
|
||||||
number: ${{ env.WORKFLOW_RUN_PR }}
|
number: ${{ steps.info.outputs.pr }}
|
||||||
message: |
|
message: ${{ steps.info.outputs.body }}
|
||||||
A new prerelease is available for testing:
|
|
||||||
|
|
||||||
```sh
|
- name: Remove the prerelease label once published
|
||||||
pnpm dlx shadcn@${{ env.BETA_PACKAGE_VERSION }}
|
if: steps.info.outputs.pr
|
||||||
```
|
|
||||||
|
|
||||||
- name: "Remove the autorelease label once published"
|
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
github.rest.issues.removeLabel({
|
try {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
issue_number: '${{ env.WORKFLOW_RUN_PR }}',
|
issue_number: Number("${{ steps.info.outputs.pr }}"),
|
||||||
name: '🚀 autorelease',
|
name: `release: ${{ steps.info.outputs.channel }}`,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status !== 404) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info("The prerelease label was already removed.");
|
||||||
|
}
|
||||||
|
|||||||
91
.github/workflows/release.yml
vendored
91
.github/workflows/release.yml
vendored
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
run-name: ${{ github.event_name == 'pull_request' && format('Release Beta - PR {0}', github.event.number) || 'Release Stable' }}
|
run-name: ${{ github.event_name == 'pull_request' && format('Release Prerelease - PR {0}', github.event.number) || 'Release Stable' }}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -15,8 +15,8 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prerelease:
|
prerelease:
|
||||||
if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && contains(github.event.pull_request.labels.*.name, '🚀 autorelease') }}
|
if: "${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && (contains(github.event.pull_request.labels.*.name, 'release: beta') || contains(github.event.pull_request.labels.*.name, 'release: rc')) }}"
|
||||||
name: Publish Beta to NPM
|
name: Publish Prerelease to NPM
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: Preview
|
environment: Preview
|
||||||
permissions:
|
permissions:
|
||||||
@@ -24,10 +24,36 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Select prerelease channel
|
||||||
|
id: prerelease
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const prereleaseLabels = [
|
||||||
|
{ name: "release: beta", channel: "beta" },
|
||||||
|
{ name: "release: rc", channel: "rc" },
|
||||||
|
];
|
||||||
|
const labels = context.payload.pull_request.labels.map((label) => label.name);
|
||||||
|
const selectedLabels = prereleaseLabels.filter((label) =>
|
||||||
|
labels.includes(label.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedLabels.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected exactly one prerelease label, found: ${
|
||||||
|
selectedLabels.map((label) => label.name).join(", ") || "none"
|
||||||
|
}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
core.setOutput("channel", selectedLabels[0].channel);
|
||||||
|
core.setOutput("label", selectedLabels[0].name);
|
||||||
|
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Use PNPM
|
- name: Use PNPM
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
@@ -47,23 +73,49 @@ jobs:
|
|||||||
- name: Install NPM Dependencies
|
- name: Install NPM Dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Modify package.json version
|
# A snapshot prerelease needs changesets to compute versions. The
|
||||||
run: node .github/version-script-beta.js
|
# Changesets version PR consumes them, so a label on that PR is a no-op.
|
||||||
|
- name: Check for changesets
|
||||||
|
id: changesets
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
present=false
|
||||||
|
for file in .changeset/*.md; do
|
||||||
|
if [ "$(basename "$file")" != "README.md" ]; then
|
||||||
|
present=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "present=$present" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Publish Beta to NPM
|
- name: No changesets to prerelease
|
||||||
run: pnpm pub:beta
|
if: steps.changesets.outputs.present == 'false'
|
||||||
|
run: echo "::notice::No changesets found on this branch; nothing to prerelease."
|
||||||
|
|
||||||
- name: get-npm-version
|
# Snapshot versions are stamped per run (timestamped), so each publish is
|
||||||
id: package-version
|
# unique and can never collide with a real release on the latest tag.
|
||||||
uses: martinbeentjes/npm-get-version-action@main
|
- name: Version snapshot
|
||||||
with:
|
if: steps.changesets.outputs.present == 'true'
|
||||||
path: packages/shadcn
|
run: pnpm exec changeset version --snapshot ${{ steps.prerelease.outputs.channel }}
|
||||||
|
|
||||||
- name: Upload packaged artifact
|
- name: Build packages
|
||||||
|
if: steps.changesets.outputs.present == 'true'
|
||||||
|
run: pnpm build:packages
|
||||||
|
|
||||||
|
- name: Publish snapshot to NPM
|
||||||
|
if: steps.changesets.outputs.present == 'true'
|
||||||
|
run: pnpm exec changeset publish --tag ${{ steps.prerelease.outputs.channel }} --no-git-tag
|
||||||
|
|
||||||
|
- name: Collect prerelease info
|
||||||
|
if: steps.changesets.outputs.present == 'true'
|
||||||
|
run: node .github/collect-prerelease-info.js "${{ github.event.number }}" "${{ steps.prerelease.outputs.channel }}"
|
||||||
|
|
||||||
|
- name: Upload prerelease info
|
||||||
|
if: steps.changesets.outputs.present == 'true'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
|
name: prerelease-info
|
||||||
path: packages/shadcn/dist/index.js
|
path: prerelease-info.json
|
||||||
|
|
||||||
release:
|
release:
|
||||||
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
|
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
|
||||||
@@ -98,11 +150,10 @@ jobs:
|
|||||||
- name: Install NPM Dependencies
|
- name: Install NPM Dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
# - name: Check for errors
|
# Builds every publishable package under packages/* (shadcn, @shadcn/react),
|
||||||
# run: pnpm check
|
# never apps/v4, so each dist is fresh before changeset publish.
|
||||||
|
- name: Build the packages
|
||||||
- name: Build the package
|
run: pnpm build:packages
|
||||||
run: pnpm shadcn:build
|
|
||||||
|
|
||||||
- name: Import GPG key
|
- name: Import GPG key
|
||||||
uses: crazy-max/ghaction-import-gpg@v6
|
uses: crazy-max/ghaction-import-gpg@v6
|
||||||
|
|||||||
51
.github/workflows/templates.yml
vendored
51
.github/workflows/templates.yml
vendored
@@ -19,7 +19,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: ${{ matrix.package-manager }} ${{ matrix.template }}
|
name: ${{ matrix.package-manager == 'pnpm' && format('pnpm {0}', matrix.pnpm-version) || matrix.package-manager }} ${{ matrix.template }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
@@ -28,11 +28,20 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
template: [next, vite, astro, start, react-router]
|
template: [next, vite, astro, start, react-router]
|
||||||
package-manager: [pnpm, bun, npm, yarn]
|
package-manager: [pnpm, bun, npm, yarn]
|
||||||
|
pnpm-version: [10.33.4, 11]
|
||||||
|
exclude:
|
||||||
|
- package-manager: bun
|
||||||
|
pnpm-version: 11
|
||||||
|
- package-manager: npm
|
||||||
|
pnpm-version: 11
|
||||||
|
- package-manager: yarn
|
||||||
|
pnpm-version: 11
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_APP_URL: http://localhost:4000
|
NEXT_PUBLIC_APP_URL: http://localhost:4000
|
||||||
NEXT_PUBLIC_V0_URL: https://v0.dev
|
NEXT_PUBLIC_V0_URL: https://v0.dev
|
||||||
REGISTRY_URL: http://localhost:4000/r
|
REGISTRY_URL: http://localhost:4000/r
|
||||||
TEMPLATE_PNPM_VERSION: 10.33.4
|
ROOT_PNPM_VERSION: 10.33.4
|
||||||
|
TEMPLATE_PNPM_VERSION: ${{ matrix.pnpm-version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -48,7 +57,7 @@ jobs:
|
|||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
id: pnpm-install
|
id: pnpm-install
|
||||||
with:
|
with:
|
||||||
version: 10.33.4
|
version: ${{ env.ROOT_PNPM_VERSION }}
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Install Bun
|
- name: Install Bun
|
||||||
@@ -131,6 +140,7 @@ jobs:
|
|||||||
local package_manager="$1"
|
local package_manager="$1"
|
||||||
local project_path="$2"
|
local project_path="$2"
|
||||||
local check_workspace_protocol="$3"
|
local check_workspace_protocol="$3"
|
||||||
|
local is_monorepo="$4"
|
||||||
|
|
||||||
cd "$project_path"
|
cd "$project_path"
|
||||||
test ! -f pnpm-workspace.yaml
|
test ! -f pnpm-workspace.yaml
|
||||||
@@ -138,6 +148,7 @@ jobs:
|
|||||||
|
|
||||||
EXPECTED_PACKAGE_MANAGER="$package_manager" \
|
EXPECTED_PACKAGE_MANAGER="$package_manager" \
|
||||||
CHECK_WORKSPACE_PROTOCOL="$check_workspace_protocol" \
|
CHECK_WORKSPACE_PROTOCOL="$check_workspace_protocol" \
|
||||||
|
IS_MONOREPO="$is_monorepo" \
|
||||||
node <<'NODE'
|
node <<'NODE'
|
||||||
const fs = require("node:fs")
|
const fs = require("node:fs")
|
||||||
const path = require("node:path")
|
const path = require("node:path")
|
||||||
@@ -145,7 +156,10 @@ jobs:
|
|||||||
const expectedPackageManager = process.env.EXPECTED_PACKAGE_MANAGER
|
const expectedPackageManager = process.env.EXPECTED_PACKAGE_MANAGER
|
||||||
const checkWorkspaceProtocol =
|
const checkWorkspaceProtocol =
|
||||||
process.env.CHECK_WORKSPACE_PROTOCOL === "true"
|
process.env.CHECK_WORKSPACE_PROTOCOL === "true"
|
||||||
|
const isMonorepo = process.env.IS_MONOREPO === "true"
|
||||||
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
||||||
|
|
||||||
|
if (isMonorepo) {
|
||||||
const workspaces = pkg.workspaces ?? []
|
const workspaces = pkg.workspaces ?? []
|
||||||
|
|
||||||
if (!Array.isArray(workspaces)) {
|
if (!Array.isArray(workspaces)) {
|
||||||
@@ -167,6 +181,17 @@ jobs:
|
|||||||
`Expected packageManager to use ${expectedPackageManager}, got ${pkg.packageManager}`
|
`Expected packageManager to use ${expectedPackageManager}, got ${pkg.packageManager}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (pkg.workspaces !== undefined) {
|
||||||
|
throw new Error("Did not expect package.json workspaces for app template.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pkg.packageManager !== undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`Did not expect packageManager for app template, got ${pkg.packageManager}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (checkWorkspaceProtocol) {
|
if (checkWorkspaceProtocol) {
|
||||||
const packageJsonFiles = []
|
const packageJsonFiles = []
|
||||||
@@ -213,8 +238,10 @@ jobs:
|
|||||||
|
|
||||||
if [ "$mode" = "monorepo" ]; then
|
if [ "$mode" = "monorepo" ]; then
|
||||||
args+=(--monorepo)
|
args+=(--monorepo)
|
||||||
|
is_monorepo="true"
|
||||||
else
|
else
|
||||||
args+=(--no-monorepo)
|
args+=(--no-monorepo)
|
||||||
|
is_monorepo="false"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "$TEMPLATE_PACKAGE_MANAGER" in
|
case "$TEMPLATE_PACKAGE_MANAGER" in
|
||||||
@@ -238,7 +265,11 @@ jobs:
|
|||||||
bunx --bun --package "$GITHUB_WORKSPACE/packages/shadcn" \
|
bunx --bun --package "$GITHUB_WORKSPACE/packages/shadcn" \
|
||||||
shadcn "${args[@]}"
|
shadcn "${args[@]}"
|
||||||
)
|
)
|
||||||
validate_non_pnpm_project "bun" "$project_path" "false"
|
validate_non_pnpm_project \
|
||||||
|
"bun" \
|
||||||
|
"$project_path" \
|
||||||
|
"false" \
|
||||||
|
"$is_monorepo"
|
||||||
;;
|
;;
|
||||||
npm)
|
npm)
|
||||||
(
|
(
|
||||||
@@ -249,7 +280,11 @@ jobs:
|
|||||||
npx --yes --package "$GITHUB_WORKSPACE/packages/shadcn" \
|
npx --yes --package "$GITHUB_WORKSPACE/packages/shadcn" \
|
||||||
shadcn "${args[@]}"
|
shadcn "${args[@]}"
|
||||||
)
|
)
|
||||||
validate_non_pnpm_project "npm" "$project_path" "true"
|
validate_non_pnpm_project \
|
||||||
|
"npm" \
|
||||||
|
"$project_path" \
|
||||||
|
"true" \
|
||||||
|
"$is_monorepo"
|
||||||
;;
|
;;
|
||||||
yarn)
|
yarn)
|
||||||
(
|
(
|
||||||
@@ -261,7 +296,11 @@ jobs:
|
|||||||
yarn dlx --package "$GITHUB_WORKSPACE/packages/shadcn" \
|
yarn dlx --package "$GITHUB_WORKSPACE/packages/shadcn" \
|
||||||
shadcn "${args[@]}"
|
shadcn "${args[@]}"
|
||||||
)
|
)
|
||||||
validate_non_pnpm_project "yarn" "$project_path" "false"
|
validate_non_pnpm_project \
|
||||||
|
"yarn" \
|
||||||
|
"$project_path" \
|
||||||
|
"false" \
|
||||||
|
"$is_monorepo"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
36
.github/workflows/test.yml
vendored
36
.github/workflows/test.yml
vendored
@@ -46,3 +46,39 @@ jobs:
|
|||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- run: pnpm test
|
- run: pnpm test
|
||||||
|
|
||||||
|
react:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: pnpm test (@shadcn/react)
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
name: Install pnpm
|
||||||
|
id: pnpm-install
|
||||||
|
with:
|
||||||
|
version: 10.33.4
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
run: |
|
||||||
|
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||||
|
- uses: actions/cache@v3
|
||||||
|
name: Setup pnpm cache
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- run: pnpm --filter=@shadcn/react test
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -45,4 +45,11 @@ tsconfig.tsbuildinfo
|
|||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
.playwright-cli
|
.playwright-cli
|
||||||
shadcn-workspace
|
shadcn-workspace
|
||||||
|
|
||||||
|
# vitest browser mode writes these only on test failure.
|
||||||
|
__screenshots__
|
||||||
.codex-artifacts
|
.codex-artifacts
|
||||||
|
.tmp*
|
||||||
|
|
||||||
|
CONTEXT.md
|
||||||
|
docs/adr
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -15,6 +15,7 @@
|
|||||||
"search.exclude": {
|
"search.exclude": {
|
||||||
"apps/v4/registry/radix-*": true,
|
"apps/v4/registry/radix-*": true,
|
||||||
"apps/v4/public/r/*": true,
|
"apps/v4/public/r/*": true,
|
||||||
"packages/shadcn/test/fixtures/*": true
|
"packages/shadcn/test/fixtures/*": true,
|
||||||
|
"apps/v4/styles/*": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ When adding or modifying components, please ensure that:
|
|||||||
2. You update the documentation.
|
2. You update the documentation.
|
||||||
3. You run `pnpm registry:build` to update the registry.
|
3. You run `pnpm registry:build` to update the registry.
|
||||||
|
|
||||||
|
See [`apps/v4/registry/README.md`](apps/v4/registry/README.md) for how the
|
||||||
|
registry pipeline is structured and for the faster targeted build modes
|
||||||
|
(`--style`, `--registry`, `--examples`, `--indexes`) you can use while
|
||||||
|
iterating locally. Always run the full `pnpm registry:build` before committing.
|
||||||
|
|
||||||
## Commit Convention
|
## Commit Convention
|
||||||
|
|
||||||
Before you create a Pull Request, please check whether your commits comply with
|
Before you create a Pull Request, please check whether your commits comply with
|
||||||
|
|||||||
59
RELEASING.md
Normal file
59
RELEASING.md
Normal 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
1
apps/v4/.gitignore
vendored
@@ -46,3 +46,4 @@ next-env.d.ts
|
|||||||
.contentlayer
|
.contentlayer
|
||||||
.content-collections
|
.content-collections
|
||||||
.source
|
.source
|
||||||
|
.devtools
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const chartData = [
|
|||||||
{ month: "Feb", amount: 900 },
|
{ month: "Feb", amount: 900 },
|
||||||
{ month: "Mar", amount: 1300 },
|
{ month: "Mar", amount: 1300 },
|
||||||
{ month: "Apr", amount: 750 },
|
{ month: "Apr", amount: 750 },
|
||||||
{ month: "May", amount: 1400 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function ContributionHistory() {
|
export function ContributionHistory() {
|
||||||
@@ -35,13 +34,14 @@ export function ContributionHistory() {
|
|||||||
role="img"
|
role="img"
|
||||||
aria-label="Last 6 months of contribution activity"
|
aria-label="Last 6 months of contribution activity"
|
||||||
>
|
>
|
||||||
{chartData.map((item) => (
|
{chartData.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item.month}
|
key={item.month}
|
||||||
className="flex h-full flex-1 flex-col justify-end gap-2"
|
className="flex h-full flex-1 flex-col justify-end gap-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="min-h-2 rounded-t-md bg-chart-2"
|
data-index={index}
|
||||||
|
className="data-[index=5]:bg-chart-6 min-h-2 rounded-lg data-[index=0]:bg-chart-1 data-[index=1]:bg-chart-2 data-[index=2]:bg-chart-3 data-[index=3]:bg-chart-4 data-[index=4]:bg-chart-5"
|
||||||
style={{ height: `${(item.amount / maxAmount) * 100}%` }}
|
style={{ height: `${(item.amount / maxAmount) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
<span className="text-center text-xs text-muted-foreground">
|
<span className="text-center text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { MessageScrollerDemo } from "@/examples/radix/message-scroller-demo"
|
||||||
|
|
||||||
import { AccountAccess } from "./account-access"
|
import { AccountAccess } from "./account-access"
|
||||||
import { AnalyticsCard } from "./analytics-card"
|
import { AnalyticsCard } from "./analytics-card"
|
||||||
import { ClaimableBalance } from "./claimable-balance"
|
import { ClaimableBalance } from "./claimable-balance"
|
||||||
@@ -79,7 +81,7 @@ export function CardsDemo() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="demo"
|
data-slot="demo"
|
||||||
className="theme-neutral relative flex w-full max-w-none flex-col gap-(--gap) overflow-hidden bg-muted p-12 pb-0! [--gap:--spacing(8)] 3xl:[--gap:--spacing(8)] min-[1900px]:p-12 min-[1900px]:[--gap:--spacing(10)]! lg:p-6 lg:[--gap:--spacing(6)] dark:bg-background"
|
className="theme-blue relative flex w-full max-w-none flex-col gap-(--gap) overflow-hidden bg-muted p-12 pb-0! [--gap:--spacing(8)] 3xl:[--gap:--spacing(8)] min-[1900px]:p-12 min-[1900px]:[--gap:--spacing(10)]! lg:p-6 lg:[--gap:--spacing(6)] dark:bg-background"
|
||||||
>
|
>
|
||||||
<CardsSkeletonRails />
|
<CardsSkeletonRails />
|
||||||
<div className="relative z-10 mx-auto grid gap-(--gap) **:data-[slot=card]:w-full min-[1400px]:grid-cols-4! min-[1900px]:grid-cols-5! md:max-w-3xl md:grid-cols-2 lg:max-w-none lg:grid-cols-3 xl:max-w-[1600px] 2xl:max-w-[1900px]">
|
<div className="relative z-10 mx-auto grid gap-(--gap) **:data-[slot=card]:w-full min-[1400px]:grid-cols-4! min-[1900px]:grid-cols-5! md:max-w-3xl md:grid-cols-2 lg:max-w-none lg:grid-cols-3 xl:max-w-[1600px] 2xl:max-w-[1900px]">
|
||||||
@@ -93,17 +95,20 @@ export function CardsDemo() {
|
|||||||
<ClaimableBalance />
|
<ClaimableBalance />
|
||||||
<DividendIncome />
|
<DividendIncome />
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden flex-col gap-(--gap) 3xl:flex!">
|
<div className="hidden flex-col gap-(--gap) min-[1400px]:flex">
|
||||||
<NewMilestone />
|
<NewMilestone />
|
||||||
<PayoutThreshold />
|
<PayoutThreshold />
|
||||||
<AccountAccess />
|
<AccountAccess />
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden flex-col gap-(--gap) md:flex">
|
<div className="hidden flex-col gap-(--gap) md:flex">
|
||||||
<QrConnect />
|
<QrConnect />
|
||||||
<TransferFunds />
|
<div className="**:[.text-center.text-xs]:hidden">
|
||||||
|
<MessageScrollerDemo />
|
||||||
|
</div>
|
||||||
|
{/* <TransferFunds /> */}
|
||||||
<Payments />
|
<Payments />
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden flex-col gap-(--gap) min-[1400px]:flex">
|
<div className="hidden flex-col gap-(--gap) min-[1900px]:flex">
|
||||||
<EmptyDistributeTrack />
|
<EmptyDistributeTrack />
|
||||||
<AnalyticsCard />
|
<AnalyticsCard />
|
||||||
<NotificationSettings />
|
<NotificationSettings />
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function UIElements() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex md:hidden style-sera:md:flex">Dialog</span>
|
<span className="flex md:hidden style-sera:md:flex">Dialog</span>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent size="sm">
|
<AlertDialogContent size="sm" className="theme-blue">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Allow accessory to connect?</AlertDialogTitle>
|
<AlertDialogTitle>Allow accessory to connect?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
|
|||||||
@@ -60,23 +60,23 @@ export default function IndexPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</PageActions>
|
</PageActions>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="container-wrapper flex-1 pb-6 md:px-0">
|
<div className="container-wrapper flex-1 p-0">
|
||||||
<div className="container overflow-hidden md:px-0 lg:max-w-none">
|
<div className="container overflow-hidden md:px-0 lg:max-w-none">
|
||||||
<section className="-mx-4 w-[160vw] overflow-hidden rounded-lg border border-border/50 md:hidden md:w-[150vw]">
|
<section className="-mx-4 w-[140vw] overflow-hidden md:hidden">
|
||||||
<Image
|
<Image
|
||||||
src="/r/styles/new-york-v4/dashboard-01-light.png"
|
src="/images/full-light.png"
|
||||||
width={1400}
|
width={2560}
|
||||||
height={875}
|
height={2764}
|
||||||
alt="Dashboard"
|
alt="Dashboard"
|
||||||
className="block dark:hidden"
|
className="block h-auto w-full dark:hidden"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<Image
|
<Image
|
||||||
src="/r/styles/new-york-v4/dashboard-01-dark.png"
|
src="/images/full-dark.png"
|
||||||
width={1400}
|
width={2560}
|
||||||
height={875}
|
height={2764}
|
||||||
alt="Dashboard"
|
alt="Dashboard"
|
||||||
className="hidden dark:block"
|
className="hidden h-auto w-full dark:block"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
72
apps/v4/app/(app)/create/components/create-devtools.tsx
Normal file
72
apps/v4/app/(app)/create/components/create-devtools.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { type RegistryItem } from "shadcn/schema"
|
|||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { getThemesForBaseColor, STYLES } from "@/registry/config"
|
import { getThemesForBaseColor, STYLES } from "@/registry/config"
|
||||||
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -32,11 +33,22 @@ import { ThemePicker } from "@/app/(app)/create/components/theme-picker"
|
|||||||
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
|
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
|
||||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||||
|
|
||||||
// Only visible when user clicks "Create Project".
|
// Only visible when user clicks "Create Project". Rendered client-only to
|
||||||
const ProjectForm = dynamic(() =>
|
// avoid a useId hydration mismatch on the Base UI dialog trigger. The loading
|
||||||
|
// placeholder mirrors the trigger button exactly so there is no layout shift.
|
||||||
|
const ProjectForm = dynamic(
|
||||||
|
() =>
|
||||||
import("@/app/(app)/create/components/project-form").then(
|
import("@/app/(app)/create/components/project-form").then(
|
||||||
(m) => m.ProjectForm
|
(m) => m.ProjectForm
|
||||||
)
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<Button disabled aria-hidden>
|
||||||
|
Get Code
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export function Customizer({
|
export function Customizer({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/create/components/action-menu"
|
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/create/components/action-menu"
|
||||||
|
import { CreateDevtools } from "@/app/(app)/create/components/create-devtools"
|
||||||
import {
|
import {
|
||||||
REDO_FORWARD_TYPE,
|
REDO_FORWARD_TYPE,
|
||||||
UNDO_FORWARD_TYPE,
|
UNDO_FORWARD_TYPE,
|
||||||
@@ -160,6 +161,7 @@ export function Preview() {
|
|||||||
title="Preview"
|
title="Preview"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<CreateDevtools />
|
||||||
<PreviewSwitcher />
|
<PreviewSwitcher />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ export async function getBaseComponent(name: string, base: BaseName) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return index[name].component
|
const { Components } = await import("@/registry/bases/__components__")
|
||||||
|
return Components[base]?.[name] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllItems() {
|
export async function getAllItems() {
|
||||||
|
|||||||
10
apps/v4/app/(app)/create/lib/devtools.ts
Normal file
10
apps/v4/app/(app)/create/lib/devtools.ts
Normal 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"
|
||||||
|
}
|
||||||
@@ -185,7 +185,7 @@ export default async function Page(props: {
|
|||||||
<div className="sticky top-[calc(var(--header-height)+1px)] z-30 ml-auto hidden h-[90svh] w-(--sidebar-width) flex-col gap-4 overflow-hidden overscroll-none pb-8 xl:flex">
|
<div className="sticky top-[calc(var(--header-height)+1px)] z-30 ml-auto hidden h-[90svh] w-(--sidebar-width) flex-col gap-4 overflow-hidden overscroll-none pb-8 xl:flex">
|
||||||
<div className="h-(--top-spacing) shrink-0"></div>
|
<div className="h-(--top-spacing) shrink-0"></div>
|
||||||
{doc.toc?.length ? (
|
{doc.toc?.length ? (
|
||||||
<div className="no-scrollbar flex flex-col gap-8 overflow-y-auto px-8">
|
<div className="flex scroll-fade scrollbar-none flex-col gap-8 overflow-y-auto px-8">
|
||||||
<DocsTableOfContents toc={doc.toc} />
|
<DocsTableOfContents toc={doc.toc} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { RandomizeScript } from "@/app/(app)/create/components/random-button"
|
|||||||
import { getBaseComponent, getBaseItem } from "@/app/(app)/create/lib/api"
|
import { getBaseComponent, getBaseItem } from "@/app/(app)/create/lib/api"
|
||||||
|
|
||||||
import "@/app/style-registry.css"
|
import "@/app/style-registry.css"
|
||||||
|
import "streamdown/styles.css"
|
||||||
|
|
||||||
export const revalidate = false
|
export const revalidate = false
|
||||||
export const dynamic = "force-static"
|
export const dynamic = "force-static"
|
||||||
|
|||||||
74
apps/v4/app/(view)/examples/[base]/[name]/page.tsx
Normal file
74
apps/v4/app/(view)/examples/[base]/[name]/page.tsx
Normal 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 />
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
@import "./legacy-themes.css";
|
@import "./legacy-themes.css";
|
||||||
|
|
||||||
|
@source "../node_modules/streamdown/dist/*.js";
|
||||||
|
|
||||||
@custom-variant style-vega (&:where(.style-vega *));
|
@custom-variant style-vega (&:where(.style-vega *));
|
||||||
@custom-variant style-nova (&:where(.style-nova *));
|
@custom-variant style-nova (&:where(.style-nova *));
|
||||||
@custom-variant style-lyra (&:where(.style-lyra *));
|
@custom-variant style-lyra (&:where(.style-lyra *));
|
||||||
@@ -284,6 +286,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-rehype-pretty-code-figure] code,
|
||||||
|
[data-rehype-pretty-code-figure] code span {
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
font-feature-settings:
|
||||||
|
"liga" 0,
|
||||||
|
"calt" 0;
|
||||||
|
}
|
||||||
|
|
||||||
[data-rehype-pretty-code-title] {
|
[data-rehype-pretty-code-title] {
|
||||||
border-bottom: color-mix(in oklab, var(--border) 30%, transparent);
|
border-bottom: color-mix(in oklab, var(--border) 30%, transparent);
|
||||||
border-bottom-width: 1px;
|
border-bottom-width: 1px;
|
||||||
|
|||||||
@@ -563,3 +563,74 @@
|
|||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-blue {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.488 0.243 264.376);
|
||||||
|
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.809 0.105 251.813);
|
||||||
|
--chart-2: oklch(0.623 0.214 259.815);
|
||||||
|
--chart-3: oklch(0.546 0.245 262.881);
|
||||||
|
--chart-4: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-5: oklch(0.424 0.199 265.638);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||||
|
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
--selection: oklch(0.93 0.03 256);
|
||||||
|
--selection-foreground: oklch(0.145 0 0);
|
||||||
|
|
||||||
|
@variant dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.424 0.199 265.638);
|
||||||
|
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.809 0.105 251.813);
|
||||||
|
--chart-2: oklch(0.623 0.214 259.815);
|
||||||
|
--chart-3: oklch(0.546 0.245 262.881);
|
||||||
|
--chart-4: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-5: oklch(0.424 0.199 265.638);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||||
|
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function Announcement() {
|
|||||||
return (
|
return (
|
||||||
<Badge asChild variant="secondary" className="bg-muted">
|
<Badge asChild variant="secondary" className="bg-muted">
|
||||||
<Link href="/docs/changelog">
|
<Link href="/docs/changelog">
|
||||||
Introducing Rhea <ArrowRightIcon />
|
Components for Chat Interfaces <ArrowRightIcon />
|
||||||
</Link>
|
</Link>
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IconArrowRight } from "@tabler/icons-react"
|
|||||||
import { useDocsSearch } from "fumadocs-core/search/client"
|
import { useDocsSearch } from "fumadocs-core/search/client"
|
||||||
import { CornerDownLeftIcon, SquareDashedIcon } from "lucide-react"
|
import { CornerDownLeftIcon, SquareDashedIcon } from "lucide-react"
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
import { encodePreset } from "shadcn/preset"
|
||||||
|
|
||||||
import { type Color, type ColorPalette } from "@/lib/colors"
|
import { type Color, type ColorPalette } from "@/lib/colors"
|
||||||
import { trackEvent } from "@/lib/events"
|
import { trackEvent } from "@/lib/events"
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
} from "@/registry/new-york-v4/ui/dialog"
|
} from "@/registry/new-york-v4/ui/dialog"
|
||||||
import { Separator } from "@/registry/new-york-v4/ui/separator"
|
import { Separator } from "@/registry/new-york-v4/ui/separator"
|
||||||
import { Spinner } from "@/registry/new-york-v4/ui/spinner"
|
import { Spinner } from "@/registry/new-york-v4/ui/spinner"
|
||||||
|
import { STYLES } from "@/registry/styles"
|
||||||
|
|
||||||
export function CommandMenu({
|
export function CommandMenu({
|
||||||
tree,
|
tree,
|
||||||
@@ -56,7 +58,7 @@ export function CommandMenu({
|
|||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false)
|
||||||
const [renderDelayedGroups, setRenderDelayedGroups] = React.useState(false)
|
const [renderDelayedGroups, setRenderDelayedGroups] = React.useState(false)
|
||||||
const [selectedType, setSelectedType] = React.useState<
|
const [selectedType, setSelectedType] = React.useState<
|
||||||
"color" | "page" | "component" | "block" | null
|
"color" | "page" | "component" | "block" | "style" | null
|
||||||
>(null)
|
>(null)
|
||||||
const [copyPayload, setCopyPayload] = React.useState("")
|
const [copyPayload, setCopyPayload] = React.useState("")
|
||||||
|
|
||||||
@@ -208,6 +210,40 @@ export function CommandMenu({
|
|||||||
)
|
)
|
||||||
}, [navItems, runCommand, router])
|
}, [navItems, runCommand, router])
|
||||||
|
|
||||||
|
const stylesSection = React.useMemo(() => {
|
||||||
|
return (
|
||||||
|
<CommandGroup
|
||||||
|
heading="Styles"
|
||||||
|
className="p-0! **:[[cmdk-group-heading]]:scroll-mt-16 **:[[cmdk-group-heading]]:p-3! **:[[cmdk-group-heading]]:pb-1!"
|
||||||
|
>
|
||||||
|
{STYLES.map((style) => (
|
||||||
|
<CommandMenuItem
|
||||||
|
key={style.name}
|
||||||
|
value={`Style ${style.title} ${style.description}`}
|
||||||
|
keywords={["style", "preset", style.name, style.title]}
|
||||||
|
onHighlight={() => {
|
||||||
|
setSelectedType("style")
|
||||||
|
setCopyPayload("")
|
||||||
|
}}
|
||||||
|
onSelect={() => {
|
||||||
|
runCommand(() =>
|
||||||
|
router.push(
|
||||||
|
`/create?preset=${encodePreset({ style: style.name })}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{style.icon}
|
||||||
|
{style.title}
|
||||||
|
<span className="ml-auto text-xs font-normal text-muted-foreground">
|
||||||
|
Open style in shadcn/create
|
||||||
|
</span>
|
||||||
|
</CommandMenuItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)
|
||||||
|
}, [runCommand, router])
|
||||||
|
|
||||||
const pageGroupsSection = React.useMemo(() => {
|
const pageGroupsSection = React.useMemo(() => {
|
||||||
return tree.children.map((group) => {
|
return tree.children.map((group) => {
|
||||||
if (group.type !== "folder") {
|
if (group.type !== "folder") {
|
||||||
@@ -425,6 +461,7 @@ export function CommandMenu({
|
|||||||
{query.isLoading ? "Searching..." : "No results found."}
|
{query.isLoading ? "Searching..." : "No results found."}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
{navItemsSection}
|
{navItemsSection}
|
||||||
|
{stylesSection}
|
||||||
{renderDelayedGroups ? (
|
{renderDelayedGroups ? (
|
||||||
<>
|
<>
|
||||||
{pageGroupsSection}
|
{pageGroupsSection}
|
||||||
@@ -448,6 +485,7 @@ export function CommandMenu({
|
|||||||
? "Go to Page"
|
? "Go to Page"
|
||||||
: null}
|
: null}
|
||||||
{selectedType === "color" ? "Copy OKLCH" : null}
|
{selectedType === "color" ? "Copy OKLCH" : null}
|
||||||
|
{selectedType === "style" ? "Open in shadcn/create" : null}
|
||||||
</div>
|
</div>
|
||||||
{copyPayload && (
|
{copyPayload && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,33 +1,65 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
import { PAGES_NEW } from "@/lib/docs"
|
import { PAGES_NEW } from "@/lib/docs"
|
||||||
import { getPagesFromFolder, type PageTreeFolder } from "@/lib/page-tree"
|
import {
|
||||||
|
getPagesFromFolder,
|
||||||
|
type PageTreeFolder,
|
||||||
|
type PageTreePage,
|
||||||
|
} from "@/lib/page-tree"
|
||||||
|
|
||||||
export function ComponentsList({
|
function ComponentLink({
|
||||||
componentsFolder,
|
component,
|
||||||
currentBase,
|
showNewIndicator,
|
||||||
}: {
|
}: {
|
||||||
componentsFolder: PageTreeFolder
|
component: PageTreePage
|
||||||
currentBase: string
|
showNewIndicator: boolean
|
||||||
}) {
|
}) {
|
||||||
const list = getPagesFromFolder(componentsFolder, currentBase)
|
const isNew = showNewIndicator && PAGES_NEW.includes(component.url)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-x-8 lg:gap-x-16 lg:gap-y-6 xl:gap-x-20">
|
|
||||||
{list.map((component) => (
|
|
||||||
<Link
|
<Link
|
||||||
key={component.$id}
|
|
||||||
href={component.url}
|
href={component.url}
|
||||||
className="inline-flex items-center gap-2 text-lg font-medium underline-offset-4 hover:underline md:text-base"
|
className="inline-flex items-center gap-2 text-lg font-medium underline-offset-4 hover:underline md:text-base"
|
||||||
>
|
>
|
||||||
{component.name}
|
{component.name}
|
||||||
{PAGES_NEW.includes(component.url) && (
|
{isNew && (
|
||||||
|
<>
|
||||||
|
<span className="sr-only">New</span>
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
className="flex size-2 rounded-full bg-blue-500"
|
className="flex size-2 rounded-full bg-blue-500"
|
||||||
title="New"
|
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComponentsList({
|
||||||
|
componentsFolder,
|
||||||
|
currentBase,
|
||||||
|
variant = "all",
|
||||||
|
}: {
|
||||||
|
componentsFolder: PageTreeFolder
|
||||||
|
currentBase: string
|
||||||
|
variant?: "all" | "new"
|
||||||
|
}) {
|
||||||
|
const list = getPagesFromFolder(componentsFolder, currentBase).filter(
|
||||||
|
(component) => variant === "all" || PAGES_NEW.includes(component.url)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8 grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-x-8 lg:gap-x-16 lg:gap-y-6 xl:gap-x-20">
|
||||||
|
{list.map((component) => (
|
||||||
|
<ComponentLink
|
||||||
|
key={component.$id}
|
||||||
|
component={component}
|
||||||
|
showNewIndicator={variant === "all"}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { BASES } from "@/registry/bases"
|
|||||||
export function DocsBaseSwitcher({
|
export function DocsBaseSwitcher({
|
||||||
base,
|
base,
|
||||||
component,
|
component,
|
||||||
|
hrefPrefix = "/docs/components",
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
base: string
|
base: string
|
||||||
component: string
|
component: string
|
||||||
|
hrefPrefix?: string
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const activeBase = BASES.find((baseItem) => base === baseItem.name)
|
const activeBase = BASES.find((baseItem) => base === baseItem.name)
|
||||||
@@ -19,7 +21,7 @@ export function DocsBaseSwitcher({
|
|||||||
{BASES.map((baseItem) => (
|
{BASES.map((baseItem) => (
|
||||||
<Link
|
<Link
|
||||||
key={baseItem.name}
|
key={baseItem.name}
|
||||||
href={`/docs/components/${baseItem.name}/${component}`}
|
href={`${hrefPrefix}/${baseItem.name}/${component}`}
|
||||||
data-active={base === baseItem.name}
|
data-active={base === baseItem.name}
|
||||||
className="relative inline-flex items-center justify-center gap-1 pt-1 pb-0.5 text-base font-medium text-muted-foreground transition-colors after:absolute after:inset-x-0 after:bottom-[-4px] after:h-0.5 after:bg-foreground after:opacity-0 after:transition-opacity hover:text-foreground data-[active=true]:text-foreground data-[active=true]:after:opacity-100"
|
className="relative inline-flex items-center justify-center gap-1 pt-1 pb-0.5 text-base font-medium text-muted-foreground transition-colors after:absolute after:inset-x-0 after:bottom-[-4px] after:h-0.5 after:bg-foreground after:opacity-0 after:transition-opacity hover:text-foreground data-[active=true]:text-foreground data-[active=true]:after:opacity-100"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -73,14 +73,12 @@ export function DocsSidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
className="sticky top-[calc(var(--header-height)+0.6rem)] z-30 hidden h-[calc(100svh-10rem)] overscroll-none bg-transparent [--sidebar-menu-width:--spacing(56)] lg:flex"
|
className="sticky top-[calc(var(--header-height)+0.6rem)] z-30 hidden h-[calc(100svh-10rem)] overflow-hidden overscroll-none bg-transparent [--sidebar-menu-width:--spacing(56)] lg:flex"
|
||||||
collapsible="none"
|
collapsible="none"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="h-9" />
|
<SidebarContent className="w-(--sidebar-menu-width) scroll-fade scrollbar-none overflow-x-hidden pl-2.5">
|
||||||
<div className="absolute top-8 z-10 h-8 w-(--sidebar-menu-width) shrink-0 bg-linear-to-b from-background via-background/80 to-background/50 blur-xs" />
|
<SidebarGroup className="pt-12">
|
||||||
<SidebarContent className="no-scrollbar w-(--sidebar-menu-width) overflow-x-hidden px-2.5">
|
|
||||||
<SidebarGroup className="pt-6">
|
|
||||||
<SidebarGroupLabel className="font-medium text-muted-foreground">
|
<SidebarGroupLabel className="font-medium text-muted-foreground">
|
||||||
Sections
|
Sections
|
||||||
</SidebarGroupLabel>
|
</SidebarGroupLabel>
|
||||||
@@ -167,7 +165,6 @@ export function DocsSidebar({
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<div className="sticky -bottom-1 z-10 h-16 shrink-0 bg-linear-to-t from-background via-background/80 to-background/50 blur-xs" />
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function DocsTableOfContents({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-2 p-4 pt-0 text-sm", className)}>
|
<div className={cn("flex flex-col gap-2 p-4 pt-0 text-sm", className)}>
|
||||||
<p className="sticky top-0 h-6 bg-background text-xs font-medium text-muted-foreground">
|
<p className="h-6 bg-background text-xs font-medium text-muted-foreground">
|
||||||
On This Page
|
On This Page
|
||||||
</p>
|
</p>
|
||||||
{toc.map((item) => (
|
{toc.map((item) => (
|
||||||
|
|||||||
28
apps/v4/components/markdown.tsx
Normal file
28
apps/v4/components/markdown.tsx
Normal 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 }
|
||||||
148
apps/v4/components/message-animated.tsx
Normal file
148
apps/v4/components/message-animated.tsx
Normal 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 }
|
||||||
645
apps/v4/components/message-parts.tsx
Normal file
645
apps/v4/components/message-parts.tsx
Normal 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,
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ title: shadcn
|
|||||||
description: Use the shadcn CLI to add components to your project.
|
description: Use the shadcn CLI to add components to your project.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
import { TriangleAlertIcon } from "lucide-react"
|
||||||
|
|
||||||
## init
|
## init
|
||||||
|
|
||||||
Use the `init` command to initialize configuration and dependencies for an existing project, or create a new project with `--name`.
|
Use the `init` command to initialize configuration and dependencies for an existing project, or create a new project with `--name`.
|
||||||
@@ -503,3 +505,87 @@ npx shadcn@latest migrate radix "src/components/ui/**"
|
|||||||
If no path is provided, the migration will transform all files in your `ui` directory (from `components.json`).
|
If no path is provided, the migration will transform all files in your `ui` directory (from `components.json`).
|
||||||
|
|
||||||
Once complete, you can remove any unused `@radix-ui/react-*` packages from your `package.json`.
|
Once complete, you can remove any unused `@radix-ui/react-*` packages from your `package.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## eject
|
||||||
|
|
||||||
|
When you run `init`, shadcn adds `@import "shadcn/tailwind.css"` to your global CSS file. This import provides shared Tailwind v4 utilities such as custom variants (`data-open:`, `data-closed:`, etc.) and accordion animations.
|
||||||
|
|
||||||
|
Use the `eject` command to inline `shadcn/tailwind.css` into your global CSS file and remove the `shadcn` dependency from your project.
|
||||||
|
|
||||||
|
<Callout icon={<TriangleAlertIcon />}>
|
||||||
|
**Note: This action is irreversible.** After ejecting, future shadcn CLI
|
||||||
|
updates to `shadcn/tailwind.css` will not apply automatically.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest eject
|
||||||
|
```
|
||||||
|
|
||||||
|
**Before**
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
```
|
||||||
|
|
||||||
|
**After**
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
/* ejected from shadcn@4.8.3 */
|
||||||
|
@theme inline {
|
||||||
|
@keyframes accordion-down {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(
|
||||||
|
--radix-accordion-content-height,
|
||||||
|
var(--accordion-panel-height, auto)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@custom-variant data-open {
|
||||||
|
&:where([data-state="open"]),
|
||||||
|
&:where([data-open]:not([data-open="false"])) {
|
||||||
|
@slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Monorepo**
|
||||||
|
|
||||||
|
In a monorepo, run the command from the workspace that contains your `components.json` and global CSS file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest eject -c packages/ui
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Usage: shadcn eject [options]
|
||||||
|
|
||||||
|
inline shadcn/tailwind.css and remove the shadcn dependency
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-c, --cwd <cwd> the working directory. defaults to the current directory.
|
||||||
|
-y, --yes skip confirmation prompt. (default: false)
|
||||||
|
-s, --silent mute output. (default: false)
|
||||||
|
-h, --help display help for command
|
||||||
|
```
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ description: Every component recreated in Figma. With customizable props, typogr
|
|||||||
- [shadcn/studio UI Kit](https://shadcnstudio.com/figma) - Accelerate design & development with a shadcn/ui compatible Figma kit with updated components, 550+ blocks, 10+ templates, 20+ themes, and an AI tool that converts designs into shadcn/ui code.
|
- [shadcn/studio UI Kit](https://shadcnstudio.com/figma) - Accelerate design & development with a shadcn/ui compatible Figma kit with updated components, 550+ blocks, 10+ templates, 20+ themes, and an AI tool that converts designs into shadcn/ui code.
|
||||||
- [Shadcnblocks.com](https://www.shadcnblocks.com) - A Premium Shadcn Figma UI Kit with components, 500+ pro blocks, shadcn theme variables, light/dark mode and Figma MCP ready.
|
- [Shadcnblocks.com](https://www.shadcnblocks.com) - A Premium Shadcn Figma UI Kit with components, 500+ pro blocks, shadcn theme variables, light/dark mode and Figma MCP ready.
|
||||||
- [Obra shadcn/ui Pro](https://shadcn.obra.studio/products/obra-shadcn-ui-pro) by [Obra Studio](https://obra.studio/) - Focused on designers who need to get work done — the best designer experience for shadcn/ui within Figma. variable consistency with shadcn, plus custom components, Pro blocks, and a design-to-code plugin.
|
- [Obra shadcn/ui Pro](https://shadcn.obra.studio/products/obra-shadcn-ui-pro) by [Obra Studio](https://obra.studio/) - Focused on designers who need to get work done — the best designer experience for shadcn/ui within Figma. variable consistency with shadcn, plus custom components, Pro blocks, and a design-to-code plugin.
|
||||||
|
- [Shadcn Space](https://shadcnspace.com/figma) - A collection of beautifully designed 320+ blocks, 9+ templates, and 250+ components with a shadcn/ui-compatible Figma kit built for modern React and Next.js workflows. Design, prototype, and ship faster using Figma components that mirror real code architecture and production-ready implementation patterns.
|
||||||
|
|||||||
61
apps/v4/content/docs/changelog/2026-05-shadcn-eject.mdx
Normal file
61
apps/v4/content/docs/changelog/2026-05-shadcn-eject.mdx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
title: May 2026 - shadcn eject
|
||||||
|
description: Inline shadcn/tailwind.css and remove the shadcn dependency.
|
||||||
|
date: 2026-05-31
|
||||||
|
---
|
||||||
|
|
||||||
|
When we added support for both Radix and Base UI, we needed a place for shared Tailwind utilities that both libraries depend on, e.g. custom variants like `data-open:` and `data-closed:` and utilities like `no-scrollbar`.
|
||||||
|
|
||||||
|
We also ran into a few bugs while working on RTL support that were easier to fix in one shared place rather than duplicating across every component.
|
||||||
|
|
||||||
|
So we created `shadcn/tailwind.css`. When you run `init`, it adds `@import "shadcn/tailwind.css"` to your global CSS file. It works just like other CSS imports such as `tw-animate-css`: a small dependency that is tree-shaken in production and resolved at build time.
|
||||||
|
|
||||||
|
If you prefer not to depend on the `shadcn` package for that CSS, we've added the `shadcn eject` command. It inlines `shadcn/tailwind.css` into your global CSS file and removes the `shadcn` dependency from your project.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest eject
|
||||||
|
```
|
||||||
|
|
||||||
|
**Before**
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
```
|
||||||
|
|
||||||
|
**After**
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
/* ejected from shadcn@4.8.3 */
|
||||||
|
@theme inline {
|
||||||
|
@keyframes accordion-down {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(
|
||||||
|
--radix-accordion-content-height,
|
||||||
|
var(--accordion-panel-height, auto)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@custom-variant data-open {
|
||||||
|
&:where([data-state="open"]),
|
||||||
|
&:where([data-open]:not([data-open="false"])) {
|
||||||
|
@slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In a monorepo, run the command from the workspace that contains your `components.json` and global CSS file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest eject -c packages/ui
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [CLI documentation](/docs/cli#eject) for more details.
|
||||||
117
apps/v4/content/docs/changelog/2026-06-chat-components.mdx
Normal file
117
apps/v4/content/docs/changelog/2026-06-chat-components.mdx
Normal 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, we’re 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. We’re 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>
|
||||||
98
apps/v4/content/docs/changelog/2026-06-github-registries.mdx
Normal file
98
apps/v4/content/docs/changelog/2026-06-github-registries.mdx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
title: June 2026 - GitHub Registries
|
||||||
|
description: Turn any public GitHub repository into a shadcn registry.
|
||||||
|
date: 2026-06-01
|
||||||
|
---
|
||||||
|
|
||||||
|
**You can now turn any public GitHub repository into a registry.**
|
||||||
|
|
||||||
|
Add a `registry.json` file at the root of the repository, define the items you
|
||||||
|
want to distribute, and users can install them directly from GitHub with the
|
||||||
|
`shadcn` CLI.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add <username>/<repo>/<item>
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, to install the `project-conventions` item from the `acme/toolkit` repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/toolkit/project-conventions
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub registries are source registries. You do not need to run `shadcn build`,
|
||||||
|
publish generated item JSON files or set up a registry server. The CLI reads the
|
||||||
|
root `registry.json`, resolves `include` entries, finds the requested item and
|
||||||
|
installs the files declared by that item.
|
||||||
|
|
||||||
|
## Distribute anything
|
||||||
|
|
||||||
|
Registry items are not limited to components. A GitHub registry can distribute
|
||||||
|
components, hooks, utilities, design tokens, feature kits, project conventions,
|
||||||
|
agent instructions, testing setup, CI workflows, release workflows, templates,
|
||||||
|
codemods, migration kits and other project files.
|
||||||
|
|
||||||
|
For example, a repository can expose a `project-conventions` item that installs
|
||||||
|
shared docs, editor settings and agent instructions:
|
||||||
|
|
||||||
|
```json title="registry.json" showLineNumbers
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme-toolkit",
|
||||||
|
"homepage": "https://github.com/acme/toolkit",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "project-conventions",
|
||||||
|
"type": "registry:item",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "AGENTS.md",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/AGENTS.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".editorconfig",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/.editorconfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/conventions.md",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/docs/conventions.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
GitHub registry addresses work with the same commands as other registry
|
||||||
|
addresses.
|
||||||
|
|
||||||
|
List items from a GitHub registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest list acme/toolkit
|
||||||
|
```
|
||||||
|
|
||||||
|
Search items:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest search acme/toolkit --query conventions
|
||||||
|
```
|
||||||
|
|
||||||
|
View an item:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest view acme/toolkit/project-conventions
|
||||||
|
```
|
||||||
|
|
||||||
|
Install an item:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/toolkit/project-conventions
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [GitHub Registries](/docs/registry/github) docs for the full guide.
|
||||||
304
apps/v4/content/docs/components/base/attachment.mdx
Normal file
304
apps/v4/content/docs/components/base/attachment.mdx
Normal 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. |
|
||||||
300
apps/v4/content/docs/components/base/bubble.mdx
Normal file
300
apps/v4/content/docs/components/base/bubble.mdx
Normal 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. |
|
||||||
@@ -103,6 +103,24 @@ Use the `size="sm"` prop to set the size of the card to small. The small size va
|
|||||||
previewClassName="h-96"
|
previewClassName="h-96"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
### Spacing
|
||||||
|
|
||||||
|
In addition to the `size` prop, you can use the `--card-spacing` CSS variable to control the spacing between sections and the inset of card parts.
|
||||||
|
|
||||||
|
<ComponentPreview
|
||||||
|
styleName="base-nova"
|
||||||
|
name="card-spacing"
|
||||||
|
previewClassName="h-[34rem]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
Use negative margins with `-mx-(--card-spacing)` to make content go edge to edge while keeping it aligned with the card inset. When the edge-to-edge content sits above a footer, use `-mb-(--card-spacing)` on `CardContent` to remove the section gap.
|
||||||
|
|
||||||
|
<ComponentPreview
|
||||||
|
styleName="base-nova"
|
||||||
|
name="card-edge-to-edge"
|
||||||
|
previewClassName="h-[24rem]"
|
||||||
|
/>
|
||||||
|
|
||||||
### Image
|
### Image
|
||||||
|
|
||||||
Add an image before the card header to create a card with an image.
|
Add an image before the card header to create a card with an image.
|
||||||
@@ -182,3 +200,70 @@ The `CardFooter` component is used for actions and secondary content at the bott
|
|||||||
| Prop | Type | Default |
|
| Prop | Type | Default |
|
||||||
| ----------- | -------- | ------- |
|
| ----------- | -------- | ------- |
|
||||||
| `className` | `string` | - |
|
| `className` | `string` | - |
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### Spacing Variable
|
||||||
|
|
||||||
|
If you're upgrading from a previous version of the `Card` component, you'll need to apply the following updates to use the `--card-spacing` variable:
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
<Step>Update the Card root spacing classes.</Step>
|
||||||
|
|
||||||
|
Replace the hard-coded gap and vertical padding with `--card-spacing`, and set the default and small size values on the root:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
className={cn(
|
||||||
|
- "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
+ "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
<Step>Update CardHeader spacing classes.</Step>
|
||||||
|
|
||||||
|
Replace the horizontal padding and border spacing with the shared variable:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
className={cn(
|
||||||
|
- "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
|
+ "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
<Step>Update CardContent and CardFooter spacing classes.</Step>
|
||||||
|
|
||||||
|
Use `--card-spacing` for the content inset and footer padding:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
- className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
|
+ className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```diff
|
||||||
|
className={cn(
|
||||||
|
- "flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||||
|
+ "flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
After applying these changes, you can customize card spacing by setting `--card-spacing` on the `Card` with an arbitrary property class:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Example() {
|
||||||
|
return <Card className="[--card-spacing:--spacing(6)]">...</Card>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
273
apps/v4/content/docs/components/base/marker.mdx
Normal file
273
apps/v4/content/docs/components/base/marker.mdx
Normal 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. |
|
||||||
586
apps/v4/content/docs/components/base/message-scroller.mdx
Normal file
586
apps/v4/content/docs/components/base/message-scroller.mdx
Normal 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, don’t pull them somewhere else. Auto-scroll should never be the default.
|
||||||
|
2. **Follow only while they’re following.** If they’re 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 what’s 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 reader’s 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.
|
||||||
248
apps/v4/content/docs/components/base/message.mdx
Normal file
248
apps/v4/content/docs/components/base/message.mdx
Normal 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. |
|
||||||
@@ -5,9 +5,11 @@
|
|||||||
"alert",
|
"alert",
|
||||||
"alert-dialog",
|
"alert-dialog",
|
||||||
"aspect-ratio",
|
"aspect-ratio",
|
||||||
|
"attachment",
|
||||||
"avatar",
|
"avatar",
|
||||||
"badge",
|
"badge",
|
||||||
"breadcrumb",
|
"breadcrumb",
|
||||||
|
"bubble",
|
||||||
"button",
|
"button",
|
||||||
"button-group",
|
"button-group",
|
||||||
"calendar",
|
"calendar",
|
||||||
@@ -35,7 +37,10 @@
|
|||||||
"item",
|
"item",
|
||||||
"kbd",
|
"kbd",
|
||||||
"label",
|
"label",
|
||||||
|
"marker",
|
||||||
"menubar",
|
"menubar",
|
||||||
|
"message",
|
||||||
|
"message-scroller",
|
||||||
"native-select",
|
"native-select",
|
||||||
"navigation-menu",
|
"navigation-menu",
|
||||||
"pagination",
|
"pagination",
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ title: Components
|
|||||||
description: Here you can find all the components available in the library. We are working on adding more components.
|
description: Here you can find all the components available in the library. We are working on adding more components.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## New Components
|
||||||
|
|
||||||
|
<ComponentsList variant="new" />
|
||||||
|
|
||||||
|
## All Components
|
||||||
|
|
||||||
<ComponentsList />
|
<ComponentsList />
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
302
apps/v4/content/docs/components/radix/attachment.mdx
Normal file
302
apps/v4/content/docs/components/radix/attachment.mdx
Normal 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. |
|
||||||
304
apps/v4/content/docs/components/radix/bubble.mdx
Normal file
304
apps/v4/content/docs/components/radix/bubble.mdx
Normal 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. |
|
||||||
@@ -103,6 +103,24 @@ Use the `size="sm"` prop to set the size of the card to small. The small size va
|
|||||||
previewClassName="h-96"
|
previewClassName="h-96"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
### Spacing
|
||||||
|
|
||||||
|
In addition to the `size` prop, you can use the `--card-spacing` CSS variable to control the spacing between sections and the inset of card parts.
|
||||||
|
|
||||||
|
<ComponentPreview
|
||||||
|
styleName="radix-nova"
|
||||||
|
name="card-spacing"
|
||||||
|
previewClassName="h-[34rem]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
Use negative margins with `-mx-(--card-spacing)` to make content go edge to edge while keeping it aligned with the card inset. When the edge-to-edge content sits above a footer, use `-mb-(--card-spacing)` on `CardContent` to remove the section gap.
|
||||||
|
|
||||||
|
<ComponentPreview
|
||||||
|
styleName="radix-nova"
|
||||||
|
name="card-edge-to-edge"
|
||||||
|
previewClassName="h-[24rem]"
|
||||||
|
/>
|
||||||
|
|
||||||
### Image
|
### Image
|
||||||
|
|
||||||
Add an image before the card header to create a card with an image.
|
Add an image before the card header to create a card with an image.
|
||||||
@@ -182,3 +200,70 @@ The `CardFooter` component is used for actions and secondary content at the bott
|
|||||||
| Prop | Type | Default |
|
| Prop | Type | Default |
|
||||||
| ----------- | -------- | ------- |
|
| ----------- | -------- | ------- |
|
||||||
| `className` | `string` | - |
|
| `className` | `string` | - |
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### Spacing Variable
|
||||||
|
|
||||||
|
If you're upgrading from a previous version of the `Card` component, you'll need to apply the following updates to use the `--card-spacing` variable:
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
<Step>Update the Card root spacing classes.</Step>
|
||||||
|
|
||||||
|
Replace the hard-coded gap and vertical padding with `--card-spacing`, and set the default and small size values on the root:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
className={cn(
|
||||||
|
- "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
+ "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
<Step>Update CardHeader spacing classes.</Step>
|
||||||
|
|
||||||
|
Replace the horizontal padding and border spacing with the shared variable:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
className={cn(
|
||||||
|
- "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
|
+ "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
<Step>Update CardContent and CardFooter spacing classes.</Step>
|
||||||
|
|
||||||
|
Use `--card-spacing` for the content inset and footer padding:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
- className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
|
+ className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```diff
|
||||||
|
className={cn(
|
||||||
|
- "flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||||
|
+ "flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
After applying these changes, you can customize card spacing by setting `--card-spacing` on the `Card` with an arbitrary property class:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Example() {
|
||||||
|
return <Card className="[--card-spacing:--spacing(6)]">...</Card>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
277
apps/v4/content/docs/components/radix/marker.mdx
Normal file
277
apps/v4/content/docs/components/radix/marker.mdx
Normal 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. |
|
||||||
586
apps/v4/content/docs/components/radix/message-scroller.mdx
Normal file
586
apps/v4/content/docs/components/radix/message-scroller.mdx
Normal 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, don’t pull them somewhere else. Auto-scroll should never be the default.
|
||||||
|
2. **Follow only while they’re following.** If they’re 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 what’s 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 reader’s 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.
|
||||||
248
apps/v4/content/docs/components/radix/message.mdx
Normal file
248
apps/v4/content/docs/components/radix/message.mdx
Normal 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. |
|
||||||
@@ -5,9 +5,11 @@
|
|||||||
"alert",
|
"alert",
|
||||||
"alert-dialog",
|
"alert-dialog",
|
||||||
"aspect-ratio",
|
"aspect-ratio",
|
||||||
|
"attachment",
|
||||||
"avatar",
|
"avatar",
|
||||||
"badge",
|
"badge",
|
||||||
"breadcrumb",
|
"breadcrumb",
|
||||||
|
"bubble",
|
||||||
"button",
|
"button",
|
||||||
"button-group",
|
"button-group",
|
||||||
"calendar",
|
"calendar",
|
||||||
@@ -35,7 +37,10 @@
|
|||||||
"item",
|
"item",
|
||||||
"kbd",
|
"kbd",
|
||||||
"label",
|
"label",
|
||||||
|
"marker",
|
||||||
"menubar",
|
"menubar",
|
||||||
|
"message",
|
||||||
|
"message-scroller",
|
||||||
"native-select",
|
"native-select",
|
||||||
"navigation-menu",
|
"navigation-menu",
|
||||||
"pagination",
|
"pagination",
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
"components",
|
"components",
|
||||||
"(root)",
|
"(root)",
|
||||||
"changelog",
|
"changelog",
|
||||||
|
"react",
|
||||||
"forms",
|
"forms",
|
||||||
"installation",
|
"installation",
|
||||||
"dark-mode",
|
"dark-mode",
|
||||||
"rtl",
|
"rtl",
|
||||||
|
"utils",
|
||||||
"registry"
|
"registry"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
277
apps/v4/content/docs/react/message-scroller.mdx
Normal file
277
apps/v4/content/docs/react/message-scroller.mdx
Normal 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.
|
||||||
4
apps/v4/content/docs/react/meta.json
Normal file
4
apps/v4/content/docs/react/meta.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"title": "@shadcn/react",
|
||||||
|
"pages": ["message-scroller"]
|
||||||
|
}
|
||||||
422
apps/v4/content/docs/registry/api-reference.mdx
Normal file
422
apps/v4/content/docs/registry/api-reference.mdx
Normal 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`
|
||||||
@@ -76,6 +76,35 @@ To add a new color you need to add it to `cssVars` under `light` and `dark` keys
|
|||||||
|
|
||||||
The CLI will update the project CSS file. Once updated, the new colors will be available to be used as utility classes: `bg-brand` and `text-brand-accent`.
|
The CLI will update the project CSS file. Once updated, the new colors will be available to be used as utility classes: `bg-brand` and `text-brand-accent`.
|
||||||
|
|
||||||
|
### Why does `button` in `registryDependencies` not resolve to my GitHub repository?
|
||||||
|
|
||||||
|
Bare registry dependency names keep the existing shadcn behavior. `button`
|
||||||
|
means the built-in shadcn `button` item.
|
||||||
|
|
||||||
|
For a dependency from a GitHub repository, use the full GitHub item address.
|
||||||
|
|
||||||
|
```json title="registry-item.json" showLineNumbers
|
||||||
|
{
|
||||||
|
"registryDependencies": ["acme/ui/button"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### How do I pin a GitHub registry item?
|
||||||
|
|
||||||
|
Add `#ref` to the GitHub item address. The ref can be a branch, tag or full
|
||||||
|
commit SHA.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/ui/button#v1.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
For published registries, prefer tags or full commit SHAs.
|
||||||
|
|
||||||
|
### Can GitHub registry addresses use private repositories?
|
||||||
|
|
||||||
|
Not currently. GitHub registry addresses support public `github.com`
|
||||||
|
repositories. For private registries, use a namespace with authenticated URLs.
|
||||||
|
|
||||||
### How do I add or override a Tailwind theme variable?
|
### How do I add or override a Tailwind theme variable?
|
||||||
|
|
||||||
To add or override a theme variable you add it to `cssVars.theme` under the key you want to add or override.
|
To add or override a theme variable you add it to `cssVars.theme` under the key you want to add or override.
|
||||||
|
|||||||
@@ -3,15 +3,19 @@ title: Getting Started
|
|||||||
description: Learn how to get setup and run your own component registry.
|
description: Learn how to get setup and run your own component registry.
|
||||||
---
|
---
|
||||||
|
|
||||||
This guide will walk you through the process of setting up your own component registry. It assumes you already have a project with components and would like to turn it into a registry.
|
This guide will walk you through the process of setting up your own registry. It assumes you already have a project with components, hooks, utilities or other files you would like to distribute.
|
||||||
|
|
||||||
|
**If you have an existing public GitHub repository, you can turn it into a
|
||||||
|
registry by adding a `registry.json` file at the root.** See
|
||||||
|
[GitHub Registries](/docs/registry/github) for details.
|
||||||
|
|
||||||
If you're starting a new registry project, you can use the [registry template](https://github.com/shadcn-ui/registry-template) as a starting point. We have already configured it for you.
|
If you're starting a new registry project, you can use the [registry template](https://github.com/shadcn-ui/registry-template) as a starting point. We have already configured it for you.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
You are free to design and host your custom registry as you see fit. The only requirement is that your registry catalog and registry items must be valid JSON files that conform to the [registry schema specification](/docs/registry/registry-json) and [registry-item schema specification](/docs/registry/registry-item-json).
|
You are free to design and publish your custom registry as you see fit. The only requirement is that your registry catalog and registry items must conform to the [registry schema specification](/docs/registry/registry-json) and [registry-item schema specification](/docs/registry/registry-item-json).
|
||||||
|
|
||||||
Your registry can be a Next.js, Vite, Vue, Svelte, PHP or any other framework as long as it supports serving JSON over HTTP.
|
Your registry can be a Next.js, Vite, Vue, Svelte, PHP or any other framework as long as it supports serving JSON over HTTP. It can also be a public GitHub repository with a `registry.json` file at the root.
|
||||||
|
|
||||||
If you'd like to see an example of a registry, we have a [template project](https://github.com/shadcn-ui/registry-template) for you to use as a starting point.
|
If you'd like to see an example of a registry, we have a [template project](https://github.com/shadcn-ui/registry-template) for you to use as a starting point.
|
||||||
|
|
||||||
@@ -638,7 +642,7 @@ Here are some guidelines to follow when building components for a registry.
|
|||||||
- Place your registry item in the `registry/[STYLE]/[NAME]` directory. I'm using `default` as an example. It can be anything you want as long as it's nested under the `registry` directory.
|
- Place your registry item in the `registry/[STYLE]/[NAME]` directory. I'm using `default` as an example. It can be anything you want as long as it's nested under the `registry` directory.
|
||||||
- For blocks, the following properties are required: `name`, `description`, `type` and `files`.
|
- For blocks, the following properties are required: `name`, `description`, `type` and `files`.
|
||||||
- It is recommended to add a proper name and description to your registry item. This helps LLMs understand the component and its purpose.
|
- It is recommended to add a proper name and description to your registry item. This helps LLMs understand the component and its purpose.
|
||||||
- Make sure to list all registry dependencies in `registryDependencies`. A registry dependency is the name of the component in the registry eg. `input`, `button`, `card`, etc or a URL to a registry item eg. `http://localhost:3000/r/editor.json`.
|
- Make sure to list all registry dependencies in `registryDependencies`. A registry dependency is an item address such as `button`, `@acme/input-form`, `acme/ui/button` or `http://localhost:3000/r/editor.json`.
|
||||||
- Make sure to list all dependencies in `dependencies`. A dependency is the name of the package in the registry eg. `zod`, `sonner`, etc. To set a version, you can use the `name@version` format eg. `zod@^3.20.0`.
|
- Make sure to list all dependencies in `dependencies`. A dependency is the name of the package in the registry eg. `zod`, `sonner`, etc. To set a version, you can use the `name@version` format eg. `zod@^3.20.0`.
|
||||||
- **Imports should always use the `@/registry` path.** eg. `import { HelloWorld } from "@/registry/default/hello-world/hello-world"`
|
- **Imports should always use the `@/registry` path.** eg. `import { HelloWorld } from "@/registry/default/hello-world/hello-world"`
|
||||||
- Ideally, place your files within a registry item in `components`, `hooks`, `lib` directories.
|
- Ideally, place your files within a registry item in `components`, `hooks`, `lib` directories.
|
||||||
|
|||||||
619
apps/v4/content/docs/registry/github.mdx
Normal file
619
apps/v4/content/docs/registry/github.mdx
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
---
|
||||||
|
title: GitHub Registries
|
||||||
|
description: Use a public GitHub repository as a registry.
|
||||||
|
---
|
||||||
|
|
||||||
|
You can now turn **any public GitHub repository into a registry.**
|
||||||
|
|
||||||
|
Add a `registry.json` file to the root of the repo, describe the files you want
|
||||||
|
to share, and users can install them with the `shadcn` CLI.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add <username>/<repo>/<item>
|
||||||
|
```
|
||||||
|
|
||||||
|
You do not need to set up a registry server or publish generated JSON files. **The GitHub repository becomes the source registry.**
|
||||||
|
|
||||||
|
## Distribute Anything
|
||||||
|
|
||||||
|
Registry items are **not limited to components or React code.** They can include
|
||||||
|
any files from your repository: source files, configuration, docs, templates,
|
||||||
|
workflows, rules or project conventions.
|
||||||
|
|
||||||
|
<div className="not-prose my-6 overflow-hidden rounded-lg border text-sm">
|
||||||
|
<div className="hidden grid-cols-[220px_1fr] border-b bg-muted/50 px-4 py-3 font-medium md:grid">
|
||||||
|
<div>Use case</div>
|
||||||
|
<div>Example files</div>
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
["Components", "components/date-picker.tsx", "components/data-table.tsx"],
|
||||||
|
[
|
||||||
|
"Helpers and utilities",
|
||||||
|
"lib/format-date.ts",
|
||||||
|
"lib/cn.ts",
|
||||||
|
"hooks/use-copy.ts",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Design system packages",
|
||||||
|
"tokens/colors.json",
|
||||||
|
"styles/theme.css",
|
||||||
|
"components/*",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Feature kits",
|
||||||
|
"app/(auth)/*",
|
||||||
|
"lib/auth.ts",
|
||||||
|
"components/login-form.tsx",
|
||||||
|
],
|
||||||
|
["Agent workflows", "AGENTS.md", ".cursor/rules/*", ".claude/commands/*"],
|
||||||
|
[
|
||||||
|
"Project conventions",
|
||||||
|
".editorconfig",
|
||||||
|
"biome.json",
|
||||||
|
"docs/conventions.md",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Codemods and migration kits",
|
||||||
|
"codemods/*",
|
||||||
|
"scripts/migrate.ts",
|
||||||
|
"docs/migration.md",
|
||||||
|
],
|
||||||
|
["Testing setup", "vitest.config.ts", "test/setup.ts", "docs/testing.md"],
|
||||||
|
[
|
||||||
|
"CI and release workflows",
|
||||||
|
".github/workflows/ci.yml",
|
||||||
|
".github/workflows/release.yml",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Project automation",
|
||||||
|
"scripts/release.ts",
|
||||||
|
"scripts/checks.ts",
|
||||||
|
"docs/automation.md",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Issue and pull request templates",
|
||||||
|
".github/ISSUE_TEMPLATE/*",
|
||||||
|
".github/pull_request_template.md",
|
||||||
|
],
|
||||||
|
["MCP configuration", ".mcp.json", ".cursor/mcp.json"],
|
||||||
|
].map(([label, ...files]) => (
|
||||||
|
<div
|
||||||
|
className="grid gap-2 border-b px-4 py-3 last:border-b-0 md:grid-cols-[220px_1fr]"
|
||||||
|
key={label}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{label}</div>
|
||||||
|
<div className="flex min-w-0 flex-wrap gap-1.5">
|
||||||
|
{files.map((file) => (
|
||||||
|
<code key={file}>{file}</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## When to use GitHub
|
||||||
|
|
||||||
|
Use a GitHub registry when:
|
||||||
|
|
||||||
|
- You already have reusable code in a public GitHub repository.
|
||||||
|
- You want users to install directly from `owner/repo/item`.
|
||||||
|
- You want to distribute config files, rules, docs, templates, utilities or
|
||||||
|
any other files from the same repository.
|
||||||
|
- You do not need private repo access or custom request authentication.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
A GitHub registry must:
|
||||||
|
|
||||||
|
- Be a public `github.com` repository.
|
||||||
|
- Have a `registry.json` file at the repository root.
|
||||||
|
- Use valid `registry.json` and `registry-item.json` schemas.
|
||||||
|
- Reference source files that exist in the repository.
|
||||||
|
|
||||||
|
Private repositories and GitHub Enterprise hosts are not currently supported by
|
||||||
|
GitHub addresses. For private or authenticated registries, use a
|
||||||
|
[namespace](/docs/registry/namespace) with
|
||||||
|
[authentication](/docs/registry/authentication).
|
||||||
|
|
||||||
|
## Step 1: Add registry.json
|
||||||
|
|
||||||
|
Given an existing public repository:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
.
|
||||||
|
├── ...
|
||||||
|
├── .editorconfig
|
||||||
|
├── AGENTS.md
|
||||||
|
└── docs
|
||||||
|
└── conventions.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `registry.json` at the root of the repository.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
.
|
||||||
|
├── ...
|
||||||
|
├── registry.json
|
||||||
|
├── .editorconfig
|
||||||
|
├── AGENTS.md
|
||||||
|
└── docs
|
||||||
|
└── conventions.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Define the item you want to distribute.
|
||||||
|
|
||||||
|
```json title="registry.json" showLineNumbers
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme-toolkit",
|
||||||
|
"homepage": "https://github.com/acme/toolkit",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "project-conventions",
|
||||||
|
"type": "registry:item",
|
||||||
|
"title": "Project Conventions",
|
||||||
|
"description": "Shared project conventions, editor settings and agent instructions.",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "AGENTS.md",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/AGENTS.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".editorconfig",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/.editorconfig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/conventions.md",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/docs/conventions.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit and push the file.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add registry.json
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "add registry"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Users can now install the item from GitHub.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/toolkit/project-conventions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Distribute any file
|
||||||
|
|
||||||
|
A registry item can install one file or many files. Use the `files` array to
|
||||||
|
declare the files that belong together.
|
||||||
|
|
||||||
|
For example, a testing setup can install a Vitest config, a setup file and a
|
||||||
|
short team guide.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
registry.json
|
||||||
|
config
|
||||||
|
└── vitest.config.ts
|
||||||
|
docs
|
||||||
|
└── testing.md
|
||||||
|
test
|
||||||
|
└── setup.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
```json title="registry.json" showLineNumbers
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme-toolkit",
|
||||||
|
"homepage": "https://github.com/acme/toolkit",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "vitest-setup",
|
||||||
|
"type": "registry:item",
|
||||||
|
"title": "Vitest Setup",
|
||||||
|
"description": "A Vitest setup with project defaults and docs.",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "config/vitest.config.ts",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/vitest.config.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "test/setup.ts",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/test/setup.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "docs/testing.md",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/docs/testing.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Users install it the same way.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/toolkit/vitest-setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `target` when a file should be written to a specific destination in the
|
||||||
|
user's project.
|
||||||
|
|
||||||
|
```json title="registry.json" showLineNumbers
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme-toolkit",
|
||||||
|
"homepage": "https://github.com/acme/toolkit",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "editorconfig",
|
||||||
|
"type": "registry:file",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "config/.editorconfig",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/.editorconfig"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/toolkit/editorconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Validate the registry
|
||||||
|
|
||||||
|
Before sharing the registry, validate it from the CLI.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest registry validate acme/toolkit
|
||||||
|
```
|
||||||
|
|
||||||
|
The command reads the root `registry.json`, resolves includes, validates the
|
||||||
|
registry items, and checks that referenced files exist.
|
||||||
|
|
||||||
|
You can also validate a branch, tag or commit SHA.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest registry validate acme/toolkit#v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: List and search items
|
||||||
|
|
||||||
|
Use `list` to see every item in the repository registry.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest list acme/toolkit
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `search` to filter the catalog.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest search acme/toolkit --query conventions
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `view` to inspect one item payload.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest view acme/toolkit/project-conventions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Organize with include
|
||||||
|
|
||||||
|
For larger repositories, keep item definitions close to the source files they
|
||||||
|
describe.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
registry.json
|
||||||
|
config
|
||||||
|
├── prettier.config.mjs
|
||||||
|
└── registry.json
|
||||||
|
rules
|
||||||
|
├── agent.md
|
||||||
|
└── registry.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The root `registry.json` can include the nested registry files.
|
||||||
|
|
||||||
|
```json title="registry.json" showLineNumbers
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme-toolkit",
|
||||||
|
"homepage": "https://github.com/acme/toolkit",
|
||||||
|
"include": ["config/registry.json", "rules/registry.json"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The included registry file declares items for that directory.
|
||||||
|
|
||||||
|
```json title="rules/registry.json" showLineNumbers
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "agent-rules",
|
||||||
|
"type": "registry:file",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "agent.md",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/AGENTS.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When using `include`, file paths are relative to the `registry.json` file that
|
||||||
|
declares the item.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/toolkit/project-conventions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registry dependencies
|
||||||
|
|
||||||
|
Use `registryDependencies` when one registry item depends on another registry
|
||||||
|
item.
|
||||||
|
|
||||||
|
### Same repository dependencies
|
||||||
|
|
||||||
|
For dependencies in the same GitHub repository, use the full GitHub item
|
||||||
|
address.
|
||||||
|
|
||||||
|
```json title="registry.json" showLineNumbers
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme-toolkit",
|
||||||
|
"homepage": "https://github.com/acme/toolkit",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "project-setup",
|
||||||
|
"type": "registry:item",
|
||||||
|
"registryDependencies": [
|
||||||
|
"acme/toolkit/agent-rules",
|
||||||
|
"acme/toolkit/prettier-config",
|
||||||
|
"acme/toolkit/tsconfig"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "docs/project-setup.md",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/docs/project-setup.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A docs item can depend on a template item from the same repository.
|
||||||
|
|
||||||
|
```json title="registry.json" showLineNumbers
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme-toolkit",
|
||||||
|
"homepage": "https://github.com/acme/toolkit",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "contributing-guide",
|
||||||
|
"type": "registry:item",
|
||||||
|
"registryDependencies": ["acme/toolkit/readme-template"],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "docs/contributing.md",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/docs/contributing.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A CI setup can depend on the same formatting and testing defaults that users can
|
||||||
|
install separately.
|
||||||
|
|
||||||
|
```json title="registry.json" showLineNumbers
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme-toolkit",
|
||||||
|
"homepage": "https://github.com/acme/toolkit",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "ci-setup",
|
||||||
|
"type": "registry:item",
|
||||||
|
"registryDependencies": [
|
||||||
|
"acme/toolkit/prettier-config",
|
||||||
|
"acme/toolkit/vitest-setup"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": ".github/workflows/ci.yml",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/.github/workflows/ci.yml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### External registry dependencies
|
||||||
|
|
||||||
|
Items can also depend on external registries. Use the full item address for the
|
||||||
|
registry that owns the dependency.
|
||||||
|
|
||||||
|
```json title="registry.json" showLineNumbers
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme-toolkit",
|
||||||
|
"homepage": "https://github.com/acme/toolkit",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "workspace-setup",
|
||||||
|
"type": "registry:item",
|
||||||
|
"registryDependencies": [
|
||||||
|
"@acme/tsconfig",
|
||||||
|
"contoso/devtools/prettier-config"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "docs/workspace.md",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/docs/workspace.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency refs
|
||||||
|
|
||||||
|
Refs are not inherited across dependencies. If a dependency should be pinned,
|
||||||
|
include its own ref.
|
||||||
|
|
||||||
|
```json title="registry.json" showLineNumbers
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme-toolkit",
|
||||||
|
"homepage": "https://github.com/acme/toolkit",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "project-setup",
|
||||||
|
"type": "registry:item",
|
||||||
|
"registryDependencies": [
|
||||||
|
"acme/toolkit/agent-rules#v1.0.0",
|
||||||
|
"acme/toolkit/tsconfig#c0ffee254729296a45d6691db565cf707a3fef5d"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "docs/project-setup.md",
|
||||||
|
"type": "registry:file",
|
||||||
|
"target": "~/docs/project-setup.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Useful commands
|
||||||
|
|
||||||
|
List every item in a GitHub registry.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest list acme/toolkit
|
||||||
|
```
|
||||||
|
|
||||||
|
Search a GitHub registry.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest search acme/toolkit -q conventions
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate a GitHub registry.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest registry validate acme/toolkit
|
||||||
|
```
|
||||||
|
|
||||||
|
Install an item from a GitHub registry.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/toolkit/project-conventions
|
||||||
|
```
|
||||||
|
|
||||||
|
View an item from a GitHub registry.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest view acme/toolkit/project-conventions
|
||||||
|
```
|
||||||
|
|
||||||
|
Install an item whose registry item name contains `/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/toolkit/rules/agent
|
||||||
|
```
|
||||||
|
|
||||||
|
<Callout>
|
||||||
|
For GitHub item addresses, the first two path segments are the GitHub owner
|
||||||
|
and repository. Any remaining segments are the registry item name, not a file
|
||||||
|
path. An address ending in `.json` is treated as a file path.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
Install from a tag.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/toolkit/project-conventions#v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Install from a full commit SHA.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/toolkit/project-conventions#c0ffee254729296a45d6691db565cf707a3fef5d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Refs
|
||||||
|
|
||||||
|
Use `#ref` to install from a branch, tag or commit SHA.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/toolkit/project-conventions#main
|
||||||
|
```
|
||||||
|
|
||||||
|
Refs may contain slashes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/toolkit/project-conventions#feature/conventions
|
||||||
|
```
|
||||||
|
|
||||||
|
If no ref is provided, the CLI uses the repository default branch.
|
||||||
|
|
||||||
|
The CLI uses Git to resolve branches, tags and short refs into a commit SHA
|
||||||
|
before reading files. Full 40-character commit SHAs are used directly and do not
|
||||||
|
require Git.
|
||||||
|
|
||||||
|
## Review before installing
|
||||||
|
|
||||||
|
GitHub registry items install code and project files from public repositories.
|
||||||
|
Treat a GitHub item address like any other third-party code dependency.
|
||||||
|
|
||||||
|
Before installing from a source you do not control:
|
||||||
|
|
||||||
|
- Review the repository and the root `registry.json`.
|
||||||
|
- Review the item definition, especially `files`, `target`, `dependencies`,
|
||||||
|
`devDependencies`, `registryDependencies` and `envVars`.
|
||||||
|
- Check any external registry dependencies. They can install files from other
|
||||||
|
registries.
|
||||||
|
- Prefer pinned refs for published install commands. A full 40-character commit
|
||||||
|
SHA is the most reproducible option.
|
||||||
|
- Use `shadcn view acme/toolkit/project-conventions` to inspect the resolved
|
||||||
|
item payload before installing.
|
||||||
|
- Pipe `shadcn view` output to your agent or review tool if you want help
|
||||||
|
checking the item.
|
||||||
|
- Use `shadcn add acme/toolkit/project-conventions --dry-run` to preview an
|
||||||
|
install without writing files.
|
||||||
|
- Use `--diff` or `--view` with `shadcn add` to inspect file changes or file
|
||||||
|
contents before applying them.
|
||||||
@@ -33,12 +33,32 @@ You can use the `shadcn` CLI to run your own code registry. Running your own reg
|
|||||||
Ready to create your own registry? In the next section, we'll walk you through setting up your own custom registry step-by-step, from creating your first component to publishing it for others to use.
|
Ready to create your own registry? In the next section, we'll walk you through setting up your own custom registry step-by-step, from creating your first component to publishing it for others to use.
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||||
<LinkedCard href="/docs/registry/getting-started" className="items-start text-sm md:p-6">
|
<LinkedCard
|
||||||
|
href="/docs/registry/getting-started"
|
||||||
|
className="items-start text-sm md:p-6"
|
||||||
|
>
|
||||||
<div className="font-medium">Getting Started</div>
|
<div className="font-medium">Getting Started</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
Set up and build your own registry
|
Set up and build your own registry
|
||||||
</div>
|
</div>
|
||||||
</LinkedCard>
|
</LinkedCard>
|
||||||
|
|
||||||
|
<LinkedCard href="/docs/registry/github" className="items-start text-sm md:p-6">
|
||||||
|
<div className="font-medium">GitHub</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Turn a GitHub repository into a registry
|
||||||
|
</div>
|
||||||
|
</LinkedCard>
|
||||||
|
|
||||||
|
<LinkedCard
|
||||||
|
href="/docs/registry/namespace"
|
||||||
|
className="items-start text-sm md:p-6"
|
||||||
|
>
|
||||||
|
<div className="font-medium">Namespaces</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Configure registries with namespaces
|
||||||
|
</div>
|
||||||
|
</LinkedCard>
|
||||||
|
|
||||||
<LinkedCard
|
<LinkedCard
|
||||||
href="/docs/registry/authentication"
|
href="/docs/registry/authentication"
|
||||||
@@ -49,31 +69,22 @@ Ready to create your own registry? In the next section, we'll walk you through s
|
|||||||
Secure your registry with authentication
|
Secure your registry with authentication
|
||||||
</div>
|
</div>
|
||||||
</LinkedCard>
|
</LinkedCard>
|
||||||
<LinkedCard
|
|
||||||
href="/docs/registry/namespace"
|
|
||||||
className="items-start text-sm md:p-6"
|
|
||||||
>
|
|
||||||
<div className="font-medium">Namespaces</div>
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
Configure registries with namespaces
|
|
||||||
</div>
|
|
||||||
</LinkedCard>
|
|
||||||
<LinkedCard
|
<LinkedCard
|
||||||
href="/docs/registry/examples"
|
href="/docs/registry/examples"
|
||||||
className="items-start text-sm md:p-6"
|
className="items-start text-sm md:p-6"
|
||||||
>
|
>
|
||||||
<div className="font-medium">Examples</div>
|
<div className="font-medium">Examples</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">Browse example registry items</div>
|
||||||
Registry item examples and configurations
|
|
||||||
</div>
|
|
||||||
</LinkedCard>
|
</LinkedCard>
|
||||||
<LinkedCard
|
|
||||||
|
<LinkedCard
|
||||||
href="/docs/registry/registry-json"
|
href="/docs/registry/registry-json"
|
||||||
className="items-start text-sm md:p-6"
|
className="items-start text-sm md:p-6"
|
||||||
>
|
>
|
||||||
<div className="font-medium">Schema</div>
|
<div className="font-medium">Schema</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
Schema specification for registry.json
|
Schema specification for registry.json
|
||||||
</div>
|
</div>
|
||||||
</LinkedCard>
|
</LinkedCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"index",
|
"index",
|
||||||
"getting-started",
|
"getting-started",
|
||||||
|
"github",
|
||||||
"registry-index",
|
"registry-index",
|
||||||
"examples",
|
"examples",
|
||||||
"namespace",
|
"namespace",
|
||||||
"authentication",
|
"authentication",
|
||||||
"mcp",
|
"mcp",
|
||||||
"open-in-v0",
|
"open-in-v0",
|
||||||
|
"api-reference",
|
||||||
"registry-json",
|
"registry-json",
|
||||||
"registry-item-json"
|
"registry-item-json"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -156,6 +156,28 @@ The pattern for referencing resources is: `@namespace/resource-name`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## GitHub and Namespaces
|
||||||
|
|
||||||
|
GitHub registry addresses and namespaces solve different problems.
|
||||||
|
|
||||||
|
Use a GitHub address when the registry is a public GitHub repository and you
|
||||||
|
want users to install without configuring `components.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add acme/ui/button
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a namespace when you want a stable alias, custom hosting, authentication,
|
||||||
|
request headers, query parameters or private registry support.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add @acme/button
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [GitHub registry](/docs/registry/github) docs for more information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Namespaced registries are configured in your `components.json` file under the `registries` field.
|
Namespaced registries are configured in your `components.json` file under the `registries` field.
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ When you run `shadcn add` or `shadcn search`, the CLI will automatically check t
|
|||||||
|
|
||||||
You can see the full list at [https://ui.shadcn.com/r/registries.json](https://ui.shadcn.com/r/registries.json).
|
You can see the full list at [https://ui.shadcn.com/r/registries.json](https://ui.shadcn.com/r/registries.json).
|
||||||
|
|
||||||
|
You do not need to submit a public GitHub registry to the registry directory to
|
||||||
|
use it with `owner/repo/item` addresses. The registry directory is for
|
||||||
|
namespaces such as `@acme`.
|
||||||
|
|
||||||
## Adding a Registry
|
## Adding a Registry
|
||||||
|
|
||||||
1. Add your registry to [`apps/v4/registry/directory.json`](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/directory.json)
|
1. Add your registry to [`apps/v4/registry/directory.json`](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/directory.json)
|
||||||
|
|||||||
@@ -161,23 +161,34 @@ Use `@version` to specify the version of the package.
|
|||||||
|
|
||||||
### registryDependencies
|
### registryDependencies
|
||||||
|
|
||||||
Used for registry dependencies. Can be names, namespaced or URLs.
|
Used for registry dependencies. Each entry is an item address.
|
||||||
|
|
||||||
- For `shadcn/ui` registry items such as `button`, `input`, `select`, etc use the name eg. `['button', 'input', 'select']`.
|
- For `shadcn/ui` registry items such as `button`, `input`, `select`, etc use the name eg. `['button', 'input', 'select']`.
|
||||||
- For namespaced registry items such as `@acme` use the name eg. `['@acme/input-form']`.
|
- For namespaced registry items, use `@namespace/item-name` eg. `['@acme/input-form']`.
|
||||||
|
- For GitHub registry items, use `owner/repo/item-name` eg. `['acme/ui/button']`. For published registries, prefer a tag or full commit SHA eg. `['acme/ui/button#v1.2.0']`.
|
||||||
- For custom registry items use the URL of the registry item eg. `['https://example.com/r/hello-world.json']`.
|
- For custom registry items use the URL of the registry item eg. `['https://example.com/r/hello-world.json']`.
|
||||||
|
- For local registry item files use a file path eg. `['./hello-world.json']`.
|
||||||
|
|
||||||
```json title="registry-item.json" showLineNumbers
|
```json title="registry-item.json" showLineNumbers
|
||||||
{
|
{
|
||||||
"registryDependencies": [
|
"registryDependencies": [
|
||||||
"button",
|
"button",
|
||||||
"@acme/input-form",
|
"@acme/input-form",
|
||||||
"https://example.com/r/editor.json"
|
"acme/ui/button#v1.2.0",
|
||||||
|
"https://example.com/r/editor.json",
|
||||||
|
"./editor.json"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: The CLI will automatically resolve remote registry dependencies.
|
Note: Bare names keep their existing behavior. `button` means the built-in
|
||||||
|
shadcn `button` item, not an item from the same GitHub repository. For
|
||||||
|
same-repository GitHub dependencies, use the full GitHub item address.
|
||||||
|
|
||||||
|
Refs are not inherited across dependencies. If a GitHub dependency should be
|
||||||
|
reproducible, pin that dependency to its own tag or full commit SHA.
|
||||||
|
|
||||||
|
See the [GitHub registry](/docs/registry/github) docs for more information.
|
||||||
|
|
||||||
### files
|
### files
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ using `include`.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Public GitHub repositories use the same source registry format. The CLI reads
|
||||||
|
the root `registry.json`, resolves `include`, and installs files from the
|
||||||
|
repository. See the [GitHub registry](/docs/registry/github) docs for more
|
||||||
|
information.
|
||||||
|
|
||||||
## Definitions
|
## Definitions
|
||||||
|
|
||||||
You can see the JSON Schema for `registry.json` [here](https://ui.shadcn.com/schema/registry.json).
|
You can see the JSON Schema for `registry.json` [here](https://ui.shadcn.com/schema/registry.json).
|
||||||
|
|||||||
4
apps/v4/content/docs/utils/meta.json
Normal file
4
apps/v4/content/docs/utils/meta.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"title": "Utilities",
|
||||||
|
"pages": ["scroll-fade", "shimmer"]
|
||||||
|
}
|
||||||
176
apps/v4/content/docs/utils/scroll-fade.mdx
Normal file
176
apps/v4/content/docs/utils/scroll-fade.mdx
Normal 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"
|
||||||
|
/>
|
||||||
167
apps/v4/content/docs/utils/shimmer.mdx
Normal file
167
apps/v4/content/docs/utils/shimmer.mdx
Normal 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…</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…</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…</p>
|
||||||
|
<p className="shimmer shimmer-color-[#378ADD]">Generating response…</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…</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…</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
For one-off values, use an arbitrary length or percentage:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<p className="shimmer shimmer-spread-[5rem]">Generating response…</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…</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…</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…</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…
|
||||||
|
</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" />
|
||||||
@@ -19,6 +19,7 @@ const eslintConfig = tseslint.config(
|
|||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
".source/**",
|
".source/**",
|
||||||
"**/__index__.tsx",
|
"**/__index__.tsx",
|
||||||
|
"**/__components__.tsx",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
8914
apps/v4/examples/__components__.tsx
Normal file
8914
apps/v4/examples/__components__.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
82
apps/v4/examples/base/attachment-demo.tsx
Normal file
82
apps/v4/examples/base/attachment-demo.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
apps/v4/examples/base/attachment-group.tsx
Normal file
71
apps/v4/examples/base/attachment-group.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
apps/v4/examples/base/attachment-image.tsx
Normal file
69
apps/v4/examples/base/attachment-image.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
apps/v4/examples/base/attachment-sizes.tsx
Normal file
42
apps/v4/examples/base/attachment-sizes.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
apps/v4/examples/base/attachment-states.tsx
Normal file
101
apps/v4/examples/base/attachment-states.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
apps/v4/examples/base/attachment-trigger.tsx
Normal file
60
apps/v4/examples/base/attachment-trigger.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
apps/v4/examples/base/bubble-alignment.tsx
Normal file
18
apps/v4/examples/base/bubble-alignment.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
apps/v4/examples/base/bubble-collapsible.tsx
Normal file
59
apps/v4/examples/base/bubble-collapsible.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
apps/v4/examples/base/bubble-demo.tsx
Normal file
48
apps/v4/examples/base/bubble-demo.tsx
Normal 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
apps/v4/examples/base/bubble-group-demo.tsx
Normal file
36
apps/v4/examples/base/bubble-group-demo.tsx
Normal 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'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's you against today's you?
|
||||||
|
It's a bit embarrassing.
|
||||||
|
</BubbleContent>
|
||||||
|
</Bubble>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
apps/v4/examples/base/bubble-link-button.tsx
Normal file
54
apps/v4/examples/base/bubble-link-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
apps/v4/examples/base/bubble-markdown.tsx
Normal file
24
apps/v4/examples/base/bubble-markdown.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
apps/v4/examples/base/bubble-popover.tsx
Normal file
55
apps/v4/examples/base/bubble-popover.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
apps/v4/examples/base/bubble-reactions.tsx
Normal file
70
apps/v4/examples/base/bubble-reactions.tsx
Normal 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'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'll add some tests. I'll let you know when
|
||||||
|
they'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
apps/v4/examples/base/bubble-tooltip.tsx
Normal file
34
apps/v4/examples/base/bubble-tooltip.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
apps/v4/examples/base/bubble-variants.tsx
Normal file
52
apps/v4/examples/base/bubble-variants.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ function Calendar({
|
|||||||
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||||
defaultClassNames.caption_label
|
defaultClassNames.caption_label
|
||||||
),
|
),
|
||||||
table: "w-full border-collapse",
|
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none",
|
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||||
|
|||||||
47
apps/v4/examples/base/card-edge-to-edge.tsx
Normal file
47
apps/v4/examples/base/card-edge-to-edge.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/styles/base-nova/ui/card"
|
||||||
|
|
||||||
|
export function CardEdgeToEdge() {
|
||||||
|
return (
|
||||||
|
<Card className="mx-auto w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Terms of Service</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Review the terms before accepting the agreement.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="-mb-(--card-spacing)">
|
||||||
|
<div className="-mx-(--card-spacing) max-h-48 space-y-4 overflow-y-scroll border-t bg-muted/50 px-(--card-spacing) py-4 text-sm leading-relaxed">
|
||||||
|
<p>
|
||||||
|
These terms govern your use of the workspace, including access to
|
||||||
|
shared documents, project files, and collaboration tools.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You are responsible for the content you upload and for ensuring that
|
||||||
|
your team has the appropriate permissions to view or edit it.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We may update features or limits as the service evolves. When those
|
||||||
|
changes materially affect your workflow, we will notify your
|
||||||
|
workspace administrators.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
By continuing, you agree to keep your account credentials secure and
|
||||||
|
to follow your organization's acceptable use policies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="justify-end gap-2">
|
||||||
|
<Button variant="outline">Decline</Button>
|
||||||
|
<Button>Accept</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
apps/v4/examples/base/card-spacing.tsx
Normal file
118
apps/v4/examples/base/card-spacing.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { Button } from "@/styles/base-nova/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/styles/base-nova/ui/card"
|
||||||
|
import { Input } from "@/styles/base-nova/ui/input"
|
||||||
|
import { Label } from "@/styles/base-nova/ui/label"
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from "@/styles/base-nova/ui/toggle-group"
|
||||||
|
|
||||||
|
const spacingOptions = [
|
||||||
|
{
|
||||||
|
className: "[--card-spacing:--spacing(4)]",
|
||||||
|
label: "16px",
|
||||||
|
value: "4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "[--card-spacing:--spacing(5)]",
|
||||||
|
label: "20px",
|
||||||
|
value: "5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "[--card-spacing:--spacing(6)]",
|
||||||
|
label: "24px",
|
||||||
|
value: "6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
className: "[--card-spacing:--spacing(8)]",
|
||||||
|
label: "32px",
|
||||||
|
value: "8",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function CardSpacing() {
|
||||||
|
const [spacing, setSpacing] = React.useState("4")
|
||||||
|
const selectedSpacing = spacingOptions.find(
|
||||||
|
(option) => option.value === spacing
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid w-full max-w-sm gap-4">
|
||||||
|
<ToggleGroup
|
||||||
|
value={[spacing]}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value[0]) {
|
||||||
|
setSpacing(value[0])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="justify-center"
|
||||||
|
>
|
||||||
|
{spacingOptions.map((option) => (
|
||||||
|
<ToggleGroupItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
<Card className={selectedSpacing?.className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Login to your account</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email below to login to your account
|
||||||
|
</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button variant="link">Sign Up</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="email-spacing">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email-spacing"
|
||||||
|
type="email"
|
||||||
|
placeholder="m@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Label htmlFor="password-spacing">Password</Label>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Input id="password-spacing" type="password" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex-col gap-2">
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
Login with Google
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
apps/v4/examples/base/markdown-demo.tsx
Normal file
43
apps/v4/examples/base/markdown-demo.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
apps/v4/examples/base/marker-border.tsx
Normal file
28
apps/v4/examples/base/marker-border.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
apps/v4/examples/base/marker-demo.tsx
Normal file
32
apps/v4/examples/base/marker-demo.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
apps/v4/examples/base/marker-icon.tsx
Normal file
28
apps/v4/examples/base/marker-icon.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
apps/v4/examples/base/marker-link-button.tsx
Normal file
33
apps/v4/examples/base/marker-link-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
apps/v4/examples/base/marker-separator.tsx
Normal file
17
apps/v4/examples/base/marker-separator.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Marker, MarkerContent } from "@/styles/base-rhea/ui/marker"
|
||||||
|
|
||||||
|
export function MarkerSeparatorDemo() {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||||
|
<Marker variant="separator">
|
||||||
|
<MarkerContent>Today</MarkerContent>
|
||||||
|
</Marker>
|
||||||
|
<Marker variant="separator">
|
||||||
|
<MarkerContent>Worked for 42s</MarkerContent>
|
||||||
|
</Marker>
|
||||||
|
<Marker variant="separator">
|
||||||
|
<MarkerContent>Conversation compacted</MarkerContent>
|
||||||
|
</Marker>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user