Compare commits

...

149 Commits

Author SHA1 Message Date
github-actions[bot]
072c27fcd5 chore(release): version packages (#10568)
Co-authored-by: shadcn <m@shadcn.com>
2026-05-21 17:57:06 +04:00
shadcn
194dcc4571 docs: update changelog 2026-05-21 17:48:36 +04:00
shadcn
51e3cfaf32 feat(registry): add validate command (#10715)
* feat: implement registry include

* feat: updates

* fix

* refactor: implementation

* fix(registry): correct directory registry json

* fix(registry): stop warning for external registry dependencies

* feat(registry): add validate command
2026-05-21 17:32:34 +04:00
shadcn
c8ab3801ec feat: add include to registry.json (#10708)
* feat: implement registry include

* feat: updates

* fix

* refactor: implementation

* fix(registry): correct directory registry json

* fix(registry): stop warning for external registry dependencies

* fix(registry): address include review feedback
2026-05-21 17:13:04 +04:00
shadcn
731e6dd8a2 fix 2026-05-20 17:27:42 +04:00
Tushar Jolly
d7066f4a2d feat: add @next-ui registry (#10693)
* feat: add @next-ui registry

## New Registry: @next-ui

- **Homepage:** https://nexus-ui.com
- **Registry URL:** https://nexus-ui.com/r/{name}.json
- **Registry index:** https://nexus-ui.com/registry.json
- **Components:** 57 motion-native components for Next.js

### Checklist
- [x] registry.json live at https://nexus-ui.com/registry.json
- [x] Component files live at https://nexus-ui.com/r/{name}.json
- [x] Open source
- [x] Works with shadcn CLI

* Add Nexus Labs registry entry to registries.json
2026-05-20 16:49:50 +04:00
Matt
5274de83d6 feat(registry): add @bklit registry (#10707)
Add Bklit UI to the open source registry index (directory.json and
registries.json) for shadcn add and shadcn search.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 16:44:20 +04:00
Fabian Hiller
7e4dac7f31 feat: add Formisch (form library) examples to docs (#10342)
* feat: add Formisch (form library) examples to docs

* docs: update Formisch docs with additional form methods and examples

* chore: update valibot dependency to version 1.4.0

* style(docs): format formisch imports

---------

Co-authored-by: shadcn <m@shadcn.com>
2026-05-20 15:18:41 +04:00
Charlie Hopkins-Brinicombe
28122dba18 feat: add @trophy-ui registry (#10700) 2026-05-20 09:44:13 +04:00
Parth Sharma
93cde61946 feat(registry): add Wensity (#10676) 2026-05-18 22:13:41 +04:00
lcorrigan704
c2dc06a99c add ui.corr.sh registry (#10675) 2026-05-18 18:57:42 +04:00
shadcn
c9930b7fda fix(toggle-group): update default spacing (#10681) 2026-05-17 12:52:42 +04:00
shadcn
d1149454a8 fix(create): preserve settings when changing style (#10680) 2026-05-17 12:31:37 +04:00
Michael McGovern
36139f6200 feat(registry): add @paddle registry (#10643)
* Add Paddle UI to directory

* Update description for @ncdai #10591
2026-05-14 15:45:15 +04:00
shadcn
15ac1be92b feat: shadcn/create for existing projects (#10622)
* feat: update project form

* feat: switch to toggle

* fix

* fix: color
2026-05-12 15:15:07 +04:00
Sergey Kuznetsov
8ca30ed32c feat(registry): add @turbopills-ui registry (#10579) 2026-05-10 20:34:04 +04:00
Alex Kostyniuk
e2605bc7c2 Update glasscn registry URL (#10597) 2026-05-10 20:33:38 +04:00
Chánh Đại
b8608d0976 Update description for @ncdai (#10591) 2026-05-09 16:50:22 +04:00
shadcn
fc1ca40af4 fix(v4): revert next dependency bump (#10584) 2026-05-08 10:28:15 +04:00
Yasuhiro Yamada
f454f6e4d1 fix(sidebar): use oklch sidebar tokens in outline shadows (#10577)
Co-authored-by: Yasuhiro Yamada <7986171+galoi@users.noreply.github.com>
2026-05-08 05:27:41 +04:00
Aniket Pawar
8cc7073aec feat(registry): add @framecn (#10558)
* Add new registry entry for @framecn

* Add new entry for @framecn with video components

* Fix JSON formatting in registries.json

* revert @text-ui in registries.json
2026-05-08 05:27:02 +04:00
shadcn
031387a471 deps: update next (#10581)
* deps: update next

* deps
2026-05-08 05:26:02 +04:00
Alexandre Schrammel
dd3567c39d feat(registry): add @amplo registry (#10573) 2026-05-07 10:27:07 +04:00
joe
ad2b8891a5 adds text-ui to the registry (#10571) 2026-05-06 23:23:40 +04:00
shadcn
f6e18c65cf perf: isr preview and llm (#10566)
* perf(create): limit preview prerender params

* perf(llm): generate markdown routes on demand
2026-05-06 14:57:42 +04:00
shadcn
1c4a53a37a test(shadcn): derive previous minor assertion (#10567)
* test(shadcn): derive previous minor assertion

* chore(changeset): add previous minor assertion

* chore: update changeset
2026-05-06 14:29:02 +04:00
shadcn
bc2db187aa feat(docs): add clickable mdx heading anchors 2026-05-06 13:27:03 +04:00
Krishna Agarwal
92b4927a80 feat(registry): add @indiacn registry (#10548) 2026-05-06 13:10:45 +04:00
Matheus Lima
3cbabe012e feat(registry): add @cognicatch to the ecosystem directory (#10217)
* feat(registry): add @cognicatch to directory

* feat(registry): add @cognicatch to directory

* build: update registries.json

* build: update registries.json

---------

Co-authored-by: shadcn <m@shadcn.com>
2026-05-06 13:09:20 +04:00
github-actions[bot]
1137b24a97 chore(release): version packages (#10556)
Co-authored-by: shadcn <m@shadcn.com>
2026-05-05 16:56:17 +04:00
shadcn
bb251e2ab6 docs: add changelog 2026-05-05 16:52:06 +04:00
shadcn
28b3e5f360 fix(cli): suggest previous minor on errors (#10559) 2026-05-05 16:15:58 +04:00
shadcn
309d95017f feat(shadcn): alias placeholders in target (#10528)
* feat: add support for package imports

* fix

* test(cli): surface add command failures

* test(cli): remove stale pnpm pin from fixture

* fix(cli): reject invalid package import targets

* fix(cli): address package import review feedback

* feat(shadcn): alias placeholders in target

* docs: update docs for alias and examples

* chore: remove lockfile drift

* chore: clean up

* fix(shadcn): route target aliases by workspace

* docs(registry): document target subdirectories
2026-05-05 14:55:47 +04:00
shadcn
eb42ae25fd feat: add support for package imports (#10519)
* feat: add support for package imports

* fix

* test(cli): surface add command failures

* test(cli): remove stale pnpm pin from fixture

* fix(cli): reject invalid package import targets

* fix(cli): address package import review feedback

* test: expand coverage

* docs: add package imports docs
2026-05-05 12:24:21 +04:00
Franco Zeta
3977fb9ace feat(registry): add @stepper registry (#10552) 2026-05-04 19:04:54 +04:00
Jay Sharma
7865621397 feat: add @evilbuttons registry with animated button components (#10536)
* feat: add @evilbuttons registry with animated button components

* feat(registry): add @delta registry (#10476)

Co-authored-by: shadcn <m@shadcn.com>

* Added @nordaun registry (#10537)

* feat: add @evilbuttons registry with animated button components

* feat(fix): fix mismatched dis...

---------

Co-authored-by: Patrick Prunty <58374462+pprunty@users.noreply.github.com>
Co-authored-by: shadcn <m@shadcn.com>
Co-authored-by: Nordaun <admin@nordaun.com>
2026-04-30 17:02:03 +04:00
Nordaun
b07070cd07 Added @nordaun registry (#10537) 2026-04-29 22:30:23 +04:00
Patrick Prunty
ad68a44717 feat(registry): add @delta registry (#10476)
Co-authored-by: shadcn <m@shadcn.com>
2026-04-29 21:09:28 +04:00
Justin Levine
56161142f1 feat: add @shieldcn registry (#10487) 2026-04-28 21:30:30 +04:00
github-actions[bot]
c2e1a5793f chore(release): version packages (#10517)
Co-authored-by: shadcn <m@shadcn.com>
2026-04-28 20:45:27 +04:00
shadcn
ea6086cbcc feat: add preset commands (#10530)
* feat(cli): add preset commands

* docs(skill): update preset command guidance

* docs(cli): document preset commands

* chore: changeset

* fix(cli): refine preset command output

* fix(cli): align preset decode output

* fix(cli): update preset output fields

* docs(changelog): add preset commands entry

* docs(changelog): show preset command output

* docs(changelog): clarify preset resolve examples

* docs(changelog): refine preset examples

* docs(changelog): add preset command sections

* docs(changelog): show preset resolve output

* docs(changelog): clarify preset open example

* docs(changelog): update preset resolve example

* docs: update preset announcement

* docs: link preset announcement to changelog

* test: increase next init timeout
2026-04-28 20:43:16 +04:00
shadcn
68a69d81f7 chore: add devl registry (#10529) 2026-04-28 12:28:07 +04:00
shadcn
55fd4dc71b feat(shadcn): add code redirect (#10526) 2026-04-28 10:32:29 +04:00
shadcn
6dea65ebcb fix(shadcn): apply for monorepo (#10524)
* fix(shadcn): apply for monorepo

* fix
2026-04-28 09:52:10 +04:00
Gurbinder
ba10089b8d feat(registry): Add @evilcharts registry (#10502) 2026-04-27 15:41:47 +04:00
shadcn
8a814f926b ci: fix signed commits permissions (#10518) 2026-04-27 15:13:07 +04:00
shadcn
c236d0c009 feat: add preset code to shadcn info (#10516)
* feat: shadcn info preset code

* chore: changeset

* refactor(shadcn): simplify color catalog

* refactor(shadcn): clean up preset resolver

* chore: format
2026-04-27 12:17:33 +04:00
Shawn.
fd0e0c369b chore: add @dotmatrix to registry directory (#10504) 2026-04-27 10:54:52 +04:00
shadcn
07d14abde1 ci: check signed commits (#10506) 2026-04-27 10:54:04 +04:00
shadcn
8dd51c49f8 fix(registry): use https for xcn homepage 2026-04-26 19:05:42 +04:00
Alex Kostyniuk
c20e0cc596 feat(registry): add @glasscn registry (#10491)
* feat(registry): add @glasscn registry

* chore(registry): remove unrelated @xcn diff
2026-04-26 19:02:23 +04:00
github-actions[bot]
0126502236 chore(release): version packages (#10489)
Co-authored-by: shadcn <m@shadcn.com>
2026-04-25 14:39:56 +04:00
shadcn
94074e4bb2 ci: release 2026-04-25 14:36:50 +04:00
shadcn
eb6e783fb3 feat: add --pointer option (#10488) 2026-04-25 14:24:25 +04:00
Wolfr
f785bfab44 docs(figma): replace Obra Studio entry with Pro edition (#10474)
- Remove free Obra shadcn/ui entry from Free section
- Add Obra shadcn/ui Pro under Paid
2026-04-25 11:26:31 +04:00
shadcn
cc20c8a794 chore: changelog 2026-04-23 10:15:13 +04:00
Mason James
05948dce8e Update shadcnblocks registry description (#10463) 2026-04-22 15:38:03 +04:00
Ray
5d23df4e35 fix(templates): add notFoundComponent to start root route (#10369) 2026-04-22 10:56:23 +04:00
akazwz
abbdd32953 fix(v4): add missing sera styles to public schema (#10452)
* Add Sera styles to v4 public schema

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(v4): tighten public schema style assertion

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: shadcn <m@shadcn.com>
2026-04-22 09:50:30 +04:00
Jay Sharma
3f14ffa632 Update URL for @xcn entry in directory.json 2026-04-21 19:27:34 +04:00
ericzakariasson
5927f6de80 chore: update cursor plugin description
Made-with: Cursor
2026-04-21 17:47:35 +04:00
shadcn
39eb34104b Merge pull request #10444 from shadcn-ui/changeset-release/main
chore(release): version packages
2026-04-21 16:28:15 +04:00
github-actions[bot]
7cbc7e8d53 chore(release): version packages 2026-04-21 12:26:54 +00:00
shadcn
d0ac558ce2 Merge pull request #10396 from ramonclaudio/docs/dark-mode-tanstack-start
docs(dark-mode): add tanstack start guide
2026-04-21 16:25:48 +04:00
shadcn
bc0c46a93c Merge pull request #10445 from ericzakariasson/add-cursor-plugin-manifest
feat: add Cursor plugin manifest
2026-04-21 16:23:46 +04:00
shadcn
a64575d8a4 Merge pull request #10454 from Bartek532/feat/loading-ui-registry
feat: add loading-ui registry to index
2026-04-21 16:22:16 +04:00
shadcn
5d0cd7819b Merge pull request #10449 from radiumcoders/main
Add @xcn registry entry
2026-04-21 16:21:23 +04:00
shadcn
13478b26b6 Merge pull request #10451 from shadcn-ui/shadcn/apply-only
feat: add apply --only
2026-04-21 16:20:14 +04:00
Jay Sharma
aee8a71679 Merge branch 'main' into main 2026-04-21 17:18:34 +05:30
Bartek532
4507f1c794 chore: refine loading-ui registry description
Update the Loading UI directory description copy and regenerate the public registries index to keep generated metadata in sync.
2026-04-21 13:22:24 +02:00
Bartosz Zagrodzki
81cd2266aa Merge branch 'main' into feat/loading-ui-registry 2026-04-21 13:20:14 +02:00
Bartek532
cf756b1b55 feat: add loading-ui registry to index
Add the Loading UI registry to the curated directory and regenerate registries.json so the new source appears in the public registry index.
2026-04-21 13:19:18 +02:00
shadcn
5e61f9c4a4 test: ensure --radius is coming through 2026-04-21 13:03:40 +04:00
shadcn
c4def9305f docs: update 2026-04-21 13:03:25 +04:00
shadcn
e456fed9d3 feat: add apply --only 2026-04-21 12:57:56 +04:00
Ray
b95cd29508 Merge branch 'main' into docs/dark-mode-tanstack-start 2026-04-21 03:46:24 -04:00
shadcn
11cbc32840 refactor: caching for build registry 2026-04-21 11:25:56 +04:00
shadcn
01539fb4d7 refactor: add getThemeScript 2026-04-21 10:35:34 +04:00
Radiumcoders
e47ee89dcf Add @xcn registry entry 2026-04-20 20:16:26 +05:30
Ray
2f5c32c0b1 Merge branch 'main' into docs/dark-mode-tanstack-start 2026-04-20 10:22:34 -04:00
ericzakariasson
fbfe9f34bb feat: add Cursor plugin manifest
Adds .cursor-plugin/plugin.json so this repo installs as a Cursor
plugin via /add-plugin shadcn-ui/ui.

- Loads the existing skills/shadcn/SKILL.md skill (auto-discovered
  via the manifest's skills field).
- Registers the shadcn MCP server (npx shadcn@latest mcp) inline so
  users get the same MCP config already documented for every other
  client without hand-editing .cursor/mcp.json.
- Reuses skills/shadcn/assets/shadcn.png as the plugin logo.

No skill content or MCP changes — purely manifest wiring.

Made-with: Cursor
2026-04-20 10:40:32 +02:00
shadcn
d55e059fda Merge pull request #10440 from uiNerd16/add-aicanvas-registry
feat: add @aicanvas registry
2026-04-20 12:37:59 +04:00
shadcn
9c572ab778 fix: chartColor in presets 2026-04-20 12:29:55 +04:00
shadcn
91403eeb63 Merge pull request #10439 from shadcn-ui/changeset-release/main
chore(release): version packages
2026-04-20 12:18:33 +04:00
github-actions[bot]
3411d53856 chore(release): version packages 2026-04-20 08:16:18 +00:00
shadcn
efa2b38d07 Merge pull request #10179 from EthanThatOneKid/fix/accept-header-issue-10164
fix(cli): add Accept: application/json header to registry fetch
2026-04-20 12:15:20 +04:00
shadcn
d00605c5fb chore: changeset 2026-04-20 11:55:18 +04:00
shadcn
4bdeea4c63 docs: update docs 2026-04-20 11:55:13 +04:00
shadcn
f632f5d798 feat: rename header 2026-04-20 11:55:06 +04:00
Ethan Davidson
7d6d489f83 Merge branch 'main' into fix/accept-header-issue-10164 2026-04-19 15:59:29 -07:00
uiNerd16
e8b1be1f22 feat: add @aicanvas registry 2026-04-19 22:10:56 +02:00
shadcn
d987955893 Merge pull request #10399 from ysds/registry-exabase
chore(registry): add @exabase registry
2026-04-19 20:54:40 +04:00
shadcn
7b5435ac0b Merge pull request #10436 from shadcn-ui/shadcn/fix-init-git-new-project-only
fix: ensure git init runs for new projects only
2026-04-19 20:49:03 +04:00
shadcn
f289497e35 Merge branch 'main' into shadcn/fix-init-git-new-project-only 2026-04-19 15:06:58 +04:00
shadcn
0d266984e6 Merge pull request #10438 from shadcn-ui/shadcn/release-workflows
Consolidate release workflows and clarify run names
2026-04-19 15:06:49 +04:00
shadcn
cf92d4f8f2 Consolidate release workflows and beta comment handling 2026-04-19 14:59:14 +04:00
shadcn
b7cfc364ac chore: changeset 2026-04-19 13:11:24 +04:00
shadcn
de385d04fc fix: ensure git init runs for new projects only 2026-04-19 12:55:07 +04:00
shadcn
b9f78c8a35 Merge pull request #10418 from shadcn-ui/changeset-release/main
chore(release): version packages
2026-04-16 20:56:50 +04:00
github-actions[bot]
97b9e7b0ae chore(release): version packages 2026-04-16 16:56:01 +00:00
shadcn
e4b25981bf Merge pull request #10416 from shadcn-ui/shadcn/style-sera
feat: sera
2026-04-16 20:54:32 +04:00
shadcn
1017410468 chore: changeset 2026-04-16 20:51:21 +04:00
shadcn
fa71bb8624 fix 2026-04-16 15:52:36 +04:00
shadcn
d99839ec2a fix 2026-04-16 15:15:30 +04:00
shadcn
70b6bfd687 fix 2026-04-16 14:32:05 +04:00
shadcn
541c08f112 fix 2026-04-16 14:22:28 +04:00
shadcn
420433ae6f Merge branch 'main' into shadcn/style-sera 2026-04-16 14:17:19 +04:00
shadcn
a7d77e0cf7 fix 2026-04-16 13:38:49 +04:00
shadcn
7ec2acc87d fix 2026-04-15 21:04:08 +04:00
ysds
eeb5d22fe5 chore(registry): add @exabase registry 2026-04-15 12:08:19 +09:00
Ray
a757e80242 docs(dark-mode): add tanstack start guide 2026-04-14 15:31:40 -04:00
shadcn
84d1d476b1 Merge pull request #9728 from htmujahid/main
Update URL for @shadcn-editor in registries.json
2026-04-14 21:03:02 +04:00
shadcn
a52a606fb5 Merge branch 'main' into shadcn/style-sera 2026-04-14 00:04:39 +04:00
shadcn
6ba39bb720 fix 2026-04-14 00:03:30 +04:00
shadcn
dd4b5c287c fix 2026-04-13 17:15:11 +04:00
shadcn
aa534e5875 fix 2026-04-13 12:59:38 +04:00
shadcn
2be9640c88 feat: build registry 2026-04-10 11:35:48 +04:00
shadcn
56567ae21a fix 2026-04-10 05:37:42 +04:00
shadcn
429e258322 fix 2026-04-09 16:12:35 +04:00
shadcn
2f57100061 feat: init 2026-04-09 13:49:02 +04:00
shadcn
fc62d5781d Merge pull request #10337 from ramonclaudio/docs/llms-txt-drift
docs(llms.txt): fix 404 and backfill missing routes
2026-04-09 05:13:18 +04:00
shadcn
d86c5e5939 Merge pull request #9484 from ramonclaudio/fix/docs-copy-page-components-list
fix(docs): replace <ComponentsList /> in copy-page and markdown output
2026-04-08 22:02:29 +04:00
shadcn
8006dd1c93 Merge branch 'main' into fix/docs-copy-page-components-list 2026-04-08 21:43:08 +04:00
Ray
1dcbb4c88a docs(llms.txt): fix 404 and backfill missing routes
llms.txt was added in #8460 and hasn't kept up with the docs tree.
Audited every URL against apps/v4/content/docs and fixed the drift
in one pass.

Removed:
- About (/docs/about): returns 404, no about.mdx exists
- Form (/docs/components/form): points at a phantom. No radix/form.mdx
  exists post-#9304. URL only resolves because of a redirect in
  next.config.mjs, which lands at /docs/forms. That page is already
  listed as 'Forms Overview' in the ## Forms section, and the real
  form library docs (React Hook Form, TanStack Form, Next.js) are
  listed there too. The Form component entry is a stale duplicate.

Added to Overview:
- Skills (/docs/skills)
- Directory (/docs/directory)

Added whole RTL section (new since #8460):
- RTL (/docs/rtl)
- RTL - Next.js
- RTL - Vite
- RTL - TanStack Start

Added to Components:
- Direction (Misc)
- Native Select (Form & Input, after Select)
- Sonner (Feedback & Status, after Toast, since Sonner has its own
  docs page even though Toast already uses it under the hood)

Added to Registry:
- Namespaces
- Add a Registry (open source registry index)
- Open in v0 integration
- registry.json schema docs
- registry-item.json spec docs

Descriptions match the short curated style of the rest of the file.
Noticed while working on #9484.
2026-04-08 12:36:44 -04:00
shadcn
4f4ffde4aa chore: update registries 2026-04-08 20:01:13 +04:00
Ray
6d7a0ed93b fix(docs): replace <ComponentsList /> in copy-page and markdown output
The <ComponentsList /> tag on /docs/components was emitted as-is by
the Copy Page button and the /llm/[slug] markdown endpoint because
getComponentsList() walked components.children for pages directly.
After #9304 restructured the folder into components/radix/ and
components/base/ subfolders, the filter always returned [] and the
tag was replaced with an empty string (or, in the copy-page case,
never replaced at all).

- Reuse getPagesFromFolder() from lib/page-tree so the walker stays
  in sync with the on-screen ComponentsList React component.
- Match the existing llms.txt format: flat absolute URLs (the
  /docs/components/:name -> /docs/components/radix/:name redirect in
  next.config.mjs is the canonical form) plus the frontmatter
  description pulled via source.getPage() on each page.
- Export replaceComponentsList() and call it from
  docs/[[...slug]]/page.tsx so the Copy Page button goes through the
  same replacement path as processMdxForLLMs.
2026-04-08 11:50:07 -04:00
shadcn
b909b0363f Merge pull request #10324 from wrappixelTeam/feat/added-shadcn-dashboard
feat(registry): added new registry ( @shadcn-dashboard )
2026-04-08 19:15:16 +04:00
shadcn
a6fa6893eb Merge pull request #10333 from kapishdima/feat/remocn
feat: added @remocn to directory.json
2026-04-08 19:08:48 +04:00
KapishDima
561586bd98 Merge branch 'main' into feat/remocn 2026-04-08 16:41:56 +03:00
kapishdima
7ddb30aade feat: added @remocn to directory.json 2026-04-08 16:38:33 +03:00
shadcn
024425d45a fix: directory pager 2026-04-08 17:05:50 +04:00
Mihir Koshti
4bdaf48f9b Merge branch 'main' into feat/added-shadcn-dashboard 2026-04-08 18:15:55 +05:30
shadcn
e9546e87ff Merge pull request #10332 from shadcn-ui/shadcn/open-preset
feat: add open preset
2026-04-08 16:43:39 +04:00
shadcn
0b34d581f9 feat: add open preset 2026-04-08 16:33:32 +04:00
shadcn
5c2ed5e90e Merge branch 'main' of github.com:shadcn-ui/ui 2026-04-08 14:42:57 +04:00
shadcn
e9443ccd4a docs: add apply changelog 2026-04-08 14:42:52 +04:00
shadcn
1fe0fe65e8 Merge pull request #10331 from shadcn-ui/shadcn/directory-refactor
refactor: directory
2026-04-08 12:31:58 +04:00
shadcn
6823bad998 refactor: directory 2026-04-08 12:23:34 +04:00
shadcn
398e6c3406 fix: formatting in registries.json 2026-04-08 11:52:03 +04:00
shadcn
710cc27de7 Merge pull request #10330 from Aniket-508/feat/termcn-directory
feat(registry): add @termcn
2026-04-08 10:42:26 +04:00
Aniket Pawar
08212a478d feat(registry): add @termcn 2026-04-08 02:47:14 +00:00
htmujahid
50dc9b506b fix: update URL for @shadcn-editor to point to raw GitHub content 2026-04-07 19:10:12 +05:00
Mihir Koshti
6b5aa16668 updated name to @shadcndashboard 2026-04-07 18:31:07 +05:30
Mihir Koshti
706806a207 feat(registry): added new registry ( @shadcn-dashboard ) 2026-04-07 18:07:26 +05:30
Talha Mujahid
8a7502d7fa Merge branch 'shadcn-ui:main' into main 2026-04-07 17:34:11 +05:00
Ethan Davidson
945298ed2d Merge branch 'main' into fix/accept-header-issue-10164 2026-03-26 00:29:45 -07:00
Ethan Davidson
f9b216af77 docs(registry): document content negotiation with Express example 2026-03-26 00:24:48 -07:00
Ethan Davidson
6525227036 fix(cli): add Accept and User-Agent headers to support content negotiation (fixes #10164) 2026-03-26 00:24:48 -07:00
Talha Mujahid
b57e192965 Update URL for @shadcn-editor in registries.json 2026-02-25 08:01:05 +05:00
1885 changed files with 62677 additions and 4164 deletions

View File

@@ -9,5 +9,6 @@
"WebFetch(domain:github.com)"
],
"deny": []
}
},
"outputStyle": "Explanatory"
}

View File

@@ -0,0 +1,41 @@
{
"name": "shadcn",
"displayName": "shadcn/ui",
"version": "1.0.0",
"description": "UI component and design system framework. Search registries, install components as source code, and audit your project.",
"author": {
"name": "shadcn"
},
"homepage": "https://ui.shadcn.com",
"repository": "https://github.com/shadcn-ui/ui",
"license": "MIT",
"logo": "skills/shadcn/assets/shadcn.png",
"keywords": [
"shadcn",
"shadcn-ui",
"ui",
"components",
"tailwind",
"tailwindcss",
"radix",
"react",
"design-system",
"registry",
"mcp"
],
"category": "developer-tools",
"tags": [
"ui",
"components",
"design-system",
"react",
"tailwind"
],
"skills": "./skills/",
"mcpServers": {
"shadcn": {
"command": "npx",
"args": ["shadcn@latest", "mcp"]
}
}
}

View File

@@ -3,7 +3,7 @@ name: Write Beta Release comment
on:
workflow_run:
workflows: ["Release - Beta"]
workflows: ["Release"]
types:
- completed
@@ -11,12 +11,13 @@ jobs:
comment:
if: |
github.repository_owner == 'shadcn-ui' &&
${{ github.event.workflow_run.conclusion == 'success' }}
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
name: Write comment to the PR
steps:
- name: "Comment on PR"
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -53,7 +54,7 @@ jobs:
```
- name: "Remove the autorelease label once published"
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@@ -1,64 +0,0 @@
# Adapted from create-t3-app.
name: Release - Beta
on:
pull_request:
types: [labeled]
branches:
- main
permissions:
id-token: write
contents: read
jobs:
prerelease:
if: |
github.repository_owner == 'shadcn-ui' &&
contains(github.event.pull_request.labels.*.name, '🚀 autorelease')
name: Build & Publish a beta release to NPM
runs-on: ubuntu-latest
environment: Preview
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
version: 9.0.6
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies
run: pnpm install
- name: Modify package.json version
run: node .github/version-script-beta.js
- name: Publish Beta to NPM
run: pnpm pub:beta
- name: get-npm-version
id: package-version
uses: martinbeentjes/npm-get-version-action@main
with:
path: packages/shadcn
- name: Upload packaged artifact
uses: actions/upload-artifact@v4
with:
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
path: packages/shadcn/dist/index.js

View File

@@ -2,24 +2,81 @@
name: Release
run-name: ${{ github.event_name == 'pull_request' && format('Release Beta - PR {0}', github.event.number) || 'Release Stable' }}
on:
pull_request:
types: [labeled]
branches:
- main
push:
branches:
- main
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
release:
if: ${{ github.repository_owner == 'shadcn-ui' }}
name: Create a PR for release workflow
prerelease:
if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && contains(github.event.pull_request.labels.*.name, '🚀 autorelease') }}
name: Publish Beta to NPM
runs-on: ubuntu-latest
environment: Preview
permissions:
id-token: write
contents: read
steps:
- name: Checkout Repo
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
version: 9.0.6
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies
run: pnpm install
- name: Modify package.json version
run: node .github/version-script-beta.js
- name: Publish Beta to NPM
run: pnpm pub:beta
- name: get-npm-version
id: package-version
uses: martinbeentjes/npm-get-version-action@main
with:
path: packages/shadcn
- name: Upload packaged artifact
uses: actions/upload-artifact@v4
with:
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
path: packages/shadcn/dist/index.js
release:
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
name: Create Version PR or Publish Stable Release
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
pull-requests: write
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -47,10 +104,19 @@ jobs:
- name: Build the package
run: pnpm shadcn:build
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
git_user_signingkey: true
git_commit_gpgsign: true
git_tag_gpgsign: true
- name: Create Version PR or Publish to NPM
id: changesets
uses: changesets/action@v1
with:
setupGitUser: false
commit: "chore(release): version packages"
title: "chore(release): version packages"
version: node .github/changeset-version.js

75
.github/workflows/signed-commits.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: Signed commits
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
- ready_for_review
permissions:
pull-requests: write
jobs:
signed-commits:
if: github.repository_owner == 'shadcn-ui'
runs-on: ubuntu-latest
name: Signed commits
steps:
- name: Check PR commits
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const body = "Can you sign the commits please? See https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits. Thank you."
const { owner, repo } = context.repo
const pullNumber = context.payload.pull_request.number
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
repo,
pull_number: pullNumber,
per_page: 100,
})
const unsignedCommits = commits.filter((commit) => {
return commit.commit.verification?.reason === "unsigned"
})
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pullNumber,
per_page: 100,
})
const existingComments = comments.filter((comment) => {
return comment.user.type === "Bot" && comment.body.trim() === body
})
if (unsignedCommits.length > 0) {
core.info(`Found ${unsignedCommits.length} unsigned commits.`)
if (existingComments.length === 0) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: pullNumber,
body,
})
}
return
}
core.info("All commits are signed.")
for (const comment of existingComments) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id,
})
}

View File

@@ -39,6 +39,9 @@ jobs:
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: pnpm install

View File

@@ -3,15 +3,12 @@ import Image from "next/image"
import Link from "next/link"
import { Announcement } from "@/components/announcement"
import { ExamplesNav } from "@/components/examples-nav"
import {
PageActions,
PageHeader,
PageHeaderDescription,
PageHeaderHeading,
} from "@/components/page-header"
import { PageNav } from "@/components/page-nav"
import { ThemeSelector } from "@/components/theme-selector"
import { Button } from "@/registry/new-york-v4/ui/button"
import { RootComponents } from "./components"

View File

@@ -0,0 +1,216 @@
"use client"
import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/styles/base-sera/ui/badge"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/styles/base-sera/ui/card"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/styles/base-sera/ui/input-group"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/styles/base-sera/ui/pagination"
import { Progress, ProgressValue } from "@/styles/base-sera/ui/progress"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/styles/base-sera/ui/table"
const ARTICLE_ROWS = [
{
title: "The Future of Sustainable Architecture",
wordProgress: "1.4k / 2.6k words",
author: "Elena Rostova",
issue: "Summer 2024",
status: "in-revision",
statusLabel: "In revision",
progress: 45,
},
{
title: "Brutalism's Second Act",
wordProgress: "2.1k / 2.5k words",
author: "Marcus Chen",
issue: "Summer 2024",
status: "final-edit",
statusLabel: "Final edit",
progress: 90,
},
{
title: "The Typography of Public Spaces",
wordProgress: "0.5k / 1.5k words",
author: "Sarah Jenkins",
issue: "Autumn 2024",
status: "drafting",
statusLabel: "Drafting",
progress: 20,
},
{
title: "Rethinking Urban Canopies",
wordProgress: "1.8k / 1.8k words",
author: "David O'Connor",
issue: "Summer 2024",
status: "published",
statusLabel: "Published",
progress: 100,
},
{
title: "Light, Glass, and the Modern Museum",
wordProgress: "1.2k / 2.0k words",
author: "Amara Osei",
issue: "Autumn 2024",
status: "in-revision",
statusLabel: "In revision",
progress: 55,
},
{
title: "Concrete Utopias: Housing in the 21st Century",
wordProgress: "3.0k / 3.0k words",
author: "Tomás Herrera",
issue: "Summer 2024",
status: "published",
statusLabel: "Published",
progress: 100,
},
{
title: "Designing for Silence",
wordProgress: "0.8k / 2.2k words",
author: "Ingrid Solberg",
issue: "Winter 2024",
status: "drafting",
statusLabel: "Drafting",
progress: 30,
},
{
title: "The Invisible Infrastructure of Cities",
wordProgress: "2.4k / 2.8k words",
author: "James Whitfield",
issue: "Autumn 2024",
status: "final-edit",
statusLabel: "Final edit",
progress: 85,
},
] as const
const STATUS_BADGE_VARIANT = {
"in-revision": "outline",
"final-edit": "default",
drafting: "ghost",
published: "secondary",
} as const
const STATUS_DOT_CLASSNAME = {
"in-revision": "bg-amber-600/80",
"final-edit": "bg-foreground/90",
drafting: "bg-muted-foreground/60",
published: "bg-emerald-600/80",
}
export function ArticleDirectory() {
return (
<Card>
<CardHeader>
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput type="search" placeholder="Search articles..." />
</InputGroup>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Title</TableHead>
<TableHead className="w-[170px]">Author</TableHead>
<TableHead className="w-[150px]">Issue</TableHead>
<TableHead className="w-[180px]">Status</TableHead>
<TableHead className="w-[140px]">Progress</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ARTICLE_ROWS.map((row) => (
<TableRow key={row.title}>
<TableCell>
<div className="flex flex-col gap-1">
<p className="font-heading text-xl tracking-tight text-foreground">
{row.title}
</p>
<p className="text-xs text-muted-foreground">
{row.wordProgress}
</p>
</div>
</TableCell>
<TableCell>{row.author}</TableCell>
<TableCell>{row.issue}</TableCell>
<TableCell>
<Badge variant={STATUS_BADGE_VARIANT[row.status]}>
<span
className={cn(
"size-1.5 rounded-full",
STATUS_DOT_CLASSNAME[row.status]
)}
/>
{row.statusLabel}
</Badge>
</TableCell>
<TableCell>
<Progress
value={row.progress}
aria-label={`${row.progress}% complete`}
className="flex flex-row-reverse items-center **:data-[slot=progress-track]:w-16"
>
<ProgressValue>
{(formattedValue) => `${formattedValue}`}
</ProgressValue>
</Progress>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
<CardFooter>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationLink
href="#"
size="icon-sm"
aria-label="Previous page"
>
<ChevronLeftIcon className="cn-rtl-flip" />
</PaginationLink>
</PaginationItem>
{[1, 2, 3].map((page) => (
<PaginationItem key={page}>
<PaginationLink href="#" size="icon-sm" isActive={page === 1}>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationLink href="#" size="icon-sm" aria-label="Next page">
<ChevronRightIcon className="cn-rtl-flip" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,47 @@
import { ArrowLeftIcon, PlusIcon } from "lucide-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList className="justify-center md:justify-start">
<BreadcrumbItem>
<BreadcrumbLink
href="#"
className="inline-flex items-center gap-1.5"
>
<ArrowLeftIcon className="size-3" />
Editorial Dashboard
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Article Directory
</h1>
</div>
<div>
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
<Button>
<PlusIcon data-icon="inline-start" />
New Article
</Button>
</ButtonGroup>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,16 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { ArticleDirectory as ArticleDirectoryList } from "./components/article-directory"
import { PreviewHeader } from "./components/preview-header"
export function ArticleDirectory() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container py-(--gap)">
<ArticleDirectoryList />
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { MoveRightIcon } from "lucide-react"
import { Button } from "@/styles/base-sera/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/styles/base-sera/ui/progress"
const DEMOGRAPHIC_DATA = [
{ age: "18 - 24", percentage: 22 },
{ age: "25 - 34", percentage: 64 },
{ age: "35 - 44", percentage: 12 },
{ age: "45+", percentage: 5 },
]
export function Demographics({ ...props }: React.ComponentProps<typeof Card>) {
return (
<Card {...props}>
<CardHeader>
<CardTitle className="text-2xl">Demographics</CardTitle>
<CardDescription>Reader Profile</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-10">
{DEMOGRAPHIC_DATA.map((item) => (
<Progress
key={item.age}
value={item.percentage}
aria-label={item.age}
>
<ProgressLabel>{item.age}</ProgressLabel>
<ProgressValue>
{(formattedValue) => `${formattedValue}`}
</ProgressValue>
</Progress>
))}
</CardContent>
<CardFooter>
<Button variant="link" className="w-full">
View all source <MoveRightIcon data-icon="inline-end" />
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,93 @@
import { TrendingDownIcon, TrendingUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
type Metric = {
label: string
value: string
comparison: string
change: string
trend: "up" | "down"
}
const METRIC_CARDS: Metric[] = [
{
label: "Total visitors",
value: "248.5k",
comparison: "12.4%",
change: "vs last period",
trend: "up",
},
{
label: "Unique readers",
value: "182.1k",
comparison: "8.7%",
change: "vs last period",
trend: "up",
},
{
label: "Avg. time on page",
value: "3m 42s",
comparison: "1.2%",
change: "vs last period",
trend: "down",
},
{
label: "Bounce rate",
value: "42.8%",
comparison: "3.5%",
change: "vs last period",
trend: "down",
},
]
export function MetricsGrid() {
return (
<>
{METRIC_CARDS.map((metric) => (
<MetricCard
key={metric.label}
metric={metric}
className="col-span-full md:col-span-6 lg:col-span-3"
/>
))}
</>
)
}
function MetricCard({
metric,
className,
}: {
metric: Metric
className: string
}) {
return (
<Card className={cn("gap-0", className)}>
<CardContent className="flex flex-col gap-2">
<CardDescription className="text-xs uppercase">
{metric.label}
</CardDescription>
<CardTitle className="text-5xl tracking-tight lowercase">
{metric.value}
</CardTitle>
<CardDescription>
{metric.trend === "up" ? (
<TrendingUpIcon className="inline-block size-2.5 text-muted-foreground" />
) : (
<TrendingDownIcon className="inline-block size-2.5 text-muted-foreground" />
)}{" "}
<span className="text-foreground">{metric.comparison}</span>{" "}
<span>{metric.change}</span>
</CardDescription>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,103 @@
"use client"
import * as React from "react"
import { ChevronDownIcon, DownloadIcon } from "lucide-react"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/styles/base-sera/ui/dropdown-menu"
const EXPORT_DATE_OPTIONS = [
{
label: "Last 7 days",
value: "last-7-days",
},
{
label: "Last 30 days",
value: "last-30-days",
},
{
label: "This month",
value: "this-month",
},
{
label: "Last month",
value: "last-month",
},
]
export function PreviewHeader() {
const [selectedDateRange, setSelectedDateRange] =
React.useState("last-30-days")
const selectedDateRangeLabel = React.useMemo(() => {
const selectedOption = EXPORT_DATE_OPTIONS.find(
(option) => option.value === selectedDateRange
)
if (!selectedOption) {
return "Last 30 days"
}
return selectedOption.label
}, [selectedDateRange])
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Audience Analytics
</h1>
<div className="line-clamp-1 text-sm font-medium tracking-wider text-muted-foreground uppercase">
Editorial Performance Dashboard
</div>
</div>
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
className="bg-background hover:bg-background/80 data-popup-open:bg-background"
/>
}
>
{selectedDateRangeLabel}{" "}
<ChevronDownIcon data-icon="inline-end" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuRadioGroup
value={selectedDateRange}
onValueChange={setSelectedDateRange}
>
{EXPORT_DATE_OPTIONS.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button>
<DownloadIcon data-icon="inline-start" />
<span className="lg:hidden">Export</span>
<span className="hidden lg:inline">Export Report</span>
</Button>
</ButtonGroup>
</div>
</header>
)
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { ArrowDownIcon, MoreHorizontalIcon } from "lucide-react"
import { Button } from "@/styles/base-sera/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/base-sera/ui/dropdown-menu"
import { Spinner } from "@/styles/base-sera/ui/spinner"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/styles/base-sera/ui/table"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/styles/base-sera/ui/toggle-group"
type EditorialMetric = "views" | "time" | "shares"
type EditorialRow = {
rank: number
title: string
author: string
published: string
pageviews: string
avgTime: string
}
const METRIC_LABEL: Record<EditorialMetric, string> = {
views: "VIEWS",
time: "TIME",
shares: "SHARES",
}
const EDITORIAL_ROWS: EditorialRow[] = [
{
rank: 1,
title: "The New Vanguard of Minimalist Architecture",
author: "Elena Rostova",
published: "Oct 12",
pageviews: "45.2k",
avgTime: "04:15",
},
{
rank: 2,
title: "Autumn Sartorial Code: Deconstructed Classics",
author: "Julian Vance",
published: "Oct 05",
pageviews: "38.9k",
avgTime: "03:42",
},
{
rank: 3,
title: "Interview: Director Sofia Coppola on The Aesthetics of Isolation",
author: "Marcus Trent",
published: "Sep 28",
pageviews: "31.4k",
avgTime: "06:20",
},
{
rank: 4,
title: "Sourcing Ceramics from Kyoto's Oldest Kilns",
author: "Sarah Lin",
published: "Oct 18",
pageviews: "22.1k",
avgTime: "02:55",
},
{
rank: 5,
title: "Field Notes from Copenhagen Design Week",
author: "Noah Bennett",
published: "Oct 21",
pageviews: "19.7k",
avgTime: "03:18",
},
{
rank: 6,
title: "A Studio Visit with Milan's Most Elusive Lighting Designer",
author: "Claire Duval",
published: "Oct 09",
pageviews: "17.4k",
avgTime: "04:02",
},
{
rank: 7,
title: "Collecting the New Avant-Garde in Contemporary Furniture",
author: "Tommy Rhodes",
published: "Sep 30",
pageviews: "15.9k",
avgTime: "03:36",
},
{
rank: 8,
title: "Inside Lisbon's Quiet Culinary Renaissance",
author: "Amara Iqbal",
published: "Oct 14",
pageviews: "14.2k",
avgTime: "05:08",
},
{
rank: 9,
title: "Why Slow Interiors Are Defining the Next Luxury Wave",
author: "Henry Vale",
published: "Oct 03",
pageviews: "12.7k",
avgTime: "03:11",
},
{
rank: 10,
title: "The Return of Print: Independent Magazine Covers to Watch",
author: "Mina Okafor",
published: "Sep 26",
pageviews: "11.3k",
avgTime: "02:49",
},
]
type TopEditorialProps = React.ComponentProps<typeof Card> & {
selectedMetric?: EditorialMetric
}
export function TopEditorial({
selectedMetric = "views",
...props
}: TopEditorialProps) {
const [visibleCount, setVisibleCount] = React.useState(5)
const [isLoadingMore, setIsLoadingMore] = React.useState(false)
const hasMoreRows = visibleCount < EDITORIAL_ROWS.length
const visibleRows = EDITORIAL_ROWS.slice(0, visibleCount)
const handleLoadMore = React.useCallback(() => {
if (!hasMoreRows || isLoadingMore) {
return
}
setIsLoadingMore(true)
window.setTimeout(() => {
setVisibleCount(EDITORIAL_ROWS.length)
setIsLoadingMore(false)
}, 2000)
}, [hasMoreRows, isLoadingMore])
return (
<Card {...props}>
<CardHeader>
<div className="flex flex-col gap-(--gap) sm:flex-row">
<div className="flex flex-col gap-1.5">
<CardTitle className="text-2xl">Top Editorials</CardTitle>
<CardDescription>Ranked by engagement</CardDescription>
</div>
<ToggleGroup
aria-label="Top editorials metric selector"
value={[selectedMetric]}
variant="outline"
className="w-full sm:ml-auto sm:w-fit"
>
{(["views", "time", "shares"] as const).map((metric) => {
return (
<ToggleGroupItem key={metric} value={metric} className="flex-1">
{METRIC_LABEL[metric]}
</ToggleGroupItem>
)
})}
</ToggleGroup>
</div>
</CardHeader>
<CardContent className="flex-1 **:data-[slot=table-container]:no-scrollbar **:data-[slot=table-container]:overflow-y-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Title</TableHead>
<TableHead>Published</TableHead>
<TableHead>Page Views</TableHead>
<TableHead>Read Time</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.rank}>
<TableCell className="translate-y-1 align-text-top">
{row.rank}
</TableCell>
<TableCell>
<div className="flex flex-col gap-2">
<p className="font-heading text-xl tracking-tight text-foreground">
{row.title}
</p>
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
By {row.author}
</p>
</div>
</TableCell>
<TableCell>{row.published}</TableCell>
<TableCell>{row.pageviews}</TableCell>
<TableCell>{row.avgTime}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-xs" />}
aria-label={`Open actions for ${row.title}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Publish</DropdownMenuItem>
<DropdownMenuItem variant="destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
<CardFooter className="justify-center">
{hasMoreRows ? (
<Button
type="button"
variant="outline"
onClick={handleLoadMore}
disabled={isLoadingMore}
>
Load more content{" "}
{isLoadingMore ? (
<Spinner data-icon="inline-end" />
) : (
<ArrowDownIcon data-icon="inline-end" />
)}
</Button>
) : null}
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import dynamic from "next/dynamic"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
const TrafficOverviewContent = dynamic(
() => import("./traffic-overview").then((mod) => mod.TrafficOverview),
{
ssr: false,
loading: () => <TrafficOverviewFallback />,
}
)
export function TrafficOverviewDeferred({
className,
...props
}: React.ComponentProps<typeof Card>) {
return (
<div className={className}>
<TrafficOverviewContent {...props} />
</div>
)
}
function TrafficOverviewFallback() {
return (
<Card>
<CardHeader>
<CardTitle className="text-2xl">Traffic Overview</CardTitle>
<CardDescription>
Traffic for the last 30 days has increased by 12.4% compared to the
previous period.
</CardDescription>
</CardHeader>
<CardContent>
<div
aria-hidden="true"
className="flex h-82 w-full flex-col justify-end gap-6 overflow-hidden bg-muted/40 p-5"
>
<div className="h-px w-full bg-border" />
<div className="h-px w-full bg-border" />
<div className="h-px w-full bg-border" />
<div className="h-px w-full bg-border" />
<div className="h-px w-full bg-border" />
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,155 @@
"use client"
import { TrendingUpIcon } from "lucide-react"
import {
CartesianGrid,
Line,
LineChart,
ReferenceDot,
XAxis,
YAxis,
} from "recharts"
import { Badge } from "@/styles/base-sera/ui/badge"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/styles/base-sera/ui/chart"
const TRAFFIC_OVERVIEW_DATA = [
{ date: "2025-10-01", views: 2600, unique: 1600 },
{ date: "2025-10-04", views: 4500, unique: 3000 },
{ date: "2025-10-08", views: 3500, unique: 2500 },
{ date: "2025-10-10", views: 6400, unique: 4500 },
{ date: "2025-10-13", views: 5400, unique: 4000 },
{ date: "2025-10-15", views: 8300, unique: 6500 },
{ date: "2025-10-17", views: 7400, unique: 6000 },
{ date: "2025-10-18", views: 9240, unique: 7105 },
{ date: "2025-10-22", views: 7700, unique: 6400 },
{ date: "2025-10-26", views: 8800, unique: 7000 },
{ date: "2025-10-29", views: 9800, unique: 8400 },
]
const TRAFFIC_CHART_CONFIG = {
views: {
label: "Views",
theme: {
light: "var(--chart-5)",
dark: "var(--chart-1)",
},
},
unique: {
label: "Unique",
theme: {
light: "var(--chart-1)",
dark: "var(--chart-2)",
},
},
} satisfies ChartConfig
const X_AXIS_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
})
function formatYAxisTick(value: number) {
if (value === 0) {
return "0"
}
if (value % 1000 === 0) {
return `${value / 1000}k`
}
return `${value / 1000}k`
}
function formatXAxisTick(value: string) {
const date = new Date(`${value}T00:00:00Z`)
if (Number.isNaN(date.getTime())) {
return value
}
return X_AXIS_DATE_FORMATTER.format(date)
}
export function TrafficOverview({
...props
}: React.ComponentProps<typeof Card>) {
return (
<Card {...props}>
<CardHeader>
<CardTitle className="text-2xl">Traffic Overview</CardTitle>
<CardDescription>
Traffic for the last 30 days has increased by 12.4% compared to the
previous period.
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={TRAFFIC_CHART_CONFIG} className="h-82 w-full">
<LineChart data={TRAFFIC_OVERVIEW_DATA}>
<CartesianGrid
vertical={false}
strokeDasharray="3 6"
stroke="var(--border)"
/>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
tickMargin={10}
tickFormatter={formatXAxisTick}
/>
<YAxis
tickLine={false}
axisLine={false}
width={44}
domain={[0, 10000]}
ticks={[0, 2500, 5000, 7500, 10000]}
tickFormatter={formatYAxisTick}
hide
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="linear"
dataKey="views"
stroke="var(--color-views)"
strokeWidth={2.2}
dot={false}
activeDot={{ r: 3.5, fill: "var(--color-views)" }}
/>
<Line
type="linear"
dataKey="unique"
stroke="var(--color-unique)"
strokeWidth={2}
strokeDasharray="4 6"
dot={false}
activeDot={false}
/>
<ReferenceDot
x="2025-10-18"
y={9240}
r={2.5}
fill="var(--color-views)"
stroke="var(--color-views)"
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,22 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { Demographics } from "./components/demographics"
import { MetricsGrid } from "./components/metrics-grid"
import { PreviewHeader } from "./components/preview-header"
import { TopEditorial } from "./components/top-editorial"
import { TrafficOverviewDeferred } from "./components/traffic-overview-deferred"
export function AudienceAnalytics() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container grid grid-cols-12 gap-(--gap) py-(--gap)">
<MetricsGrid />
<TrafficOverviewDeferred className="col-span-full md:col-span-6 lg:col-span-8" />
<Demographics className="col-span-full md:col-span-6 lg:col-span-4" />
<TopEditorial className="col-span-full" />
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import Image from "next/image"
import { cn } from "@/lib/utils"
export function ImagePreview() {
return (
<div className="mt-8 flex flex-col overflow-hidden md:hidden">
<ImagePreviewItem name="sera-01" />
<ImagePreviewItem name="sera-03" />
<ImagePreviewItem name="sera-02" />
<ImagePreviewItem name="sera-06" />
</div>
)
}
function ImagePreviewItem({
name,
className,
}: {
name: string
className?: string
}) {
return (
<div
className={cn(
"theme-taupe overflow-hidden bg-muted px-4 py-2 first:pt-4 last:pb-4",
className
)}
>
<Image
src={`/images/${name}-light.png`}
alt={name}
width={1440}
height={900}
className="dark:hidden"
/>
<Image
src={`/images/${name}-dark.png`}
alt={name}
width={1440}
height={900}
className="hidden dark:block"
/>
</div>
)
}

View File

@@ -0,0 +1,148 @@
"use client"
import * as React from "react"
import dynamic from "next/dynamic"
type LazyPreviewName =
| "articleDirectory"
| "emptyState"
| "editArticle"
| "mediaLibrary"
| "mediaLibraryTable"
const PREVIEW_MIN_HEIGHTS: Record<LazyPreviewName, number> = {
articleDirectory: 760,
emptyState: 560,
editArticle: 980,
mediaLibrary: 880,
mediaLibraryTable: 980,
}
const ArticleDirectoryPreview = dynamic(
() => import("../article-directory").then((mod) => mod.ArticleDirectory),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.articleDirectory} />
),
}
)
const EmptyStatePreview = dynamic(
() => import("../empty-state").then((mod) => mod.EmptyState),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.emptyState} />
),
}
)
const EditArticlePreview = dynamic(
() => import("../edit-article").then((mod) => mod.EditArticle),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.editArticle} />
),
}
)
const MediaLibraryPreview = dynamic(
() => import("../media-library").then((mod) => mod.MediaLibrary),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.mediaLibrary} />
),
}
)
const MediaLibraryTablePreview = dynamic(
() => import("../media-library-table").then((mod) => mod.MediaLibraryTable),
{
ssr: false,
loading: () => (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS.mediaLibraryTable} />
),
}
)
const PREVIEW_COMPONENTS: Record<LazyPreviewName, React.ComponentType> = {
articleDirectory: ArticleDirectoryPreview,
emptyState: EmptyStatePreview,
editArticle: EditArticlePreview,
mediaLibrary: MediaLibraryPreview,
mediaLibraryTable: MediaLibraryTablePreview,
}
export function LazyPreview({ name }: { name: LazyPreviewName }) {
const containerRef = React.useRef<HTMLDivElement>(null)
const [shouldRender, setShouldRender] = React.useState(false)
const PreviewComponent = PREVIEW_COMPONENTS[name]
React.useEffect(() => {
if (shouldRender) {
return
}
const container = containerRef.current
if (!container || !("IntersectionObserver" in window)) {
setShouldRender(true)
return
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) {
return
}
setShouldRender(true)
observer.disconnect()
},
{
rootMargin: "800px 0px",
}
)
observer.observe(container)
return () => observer.disconnect()
}, [shouldRender])
return (
<div ref={containerRef}>
{shouldRender ? (
<PreviewComponent />
) : (
<PreviewPlaceholder minHeight={PREVIEW_MIN_HEIGHTS[name]} />
)}
</div>
)
}
function PreviewPlaceholder({ minHeight }: { minHeight: number }) {
return (
<div
aria-hidden="true"
className="preview theme-taupe @container/preview w-full flex-1 bg-muted p-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:p-6 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)]"
style={{ minHeight }}
>
<div className="container flex flex-col gap-(--gap) py-(--gap)">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-3">
<div className="h-5 w-44 bg-background/80" />
<div className="h-3 w-56 max-w-full bg-background/60" />
</div>
<div className="hidden h-8 w-28 bg-background/70 sm:block" />
</div>
<div className="grid grid-cols-1 gap-(--gap) md:grid-cols-3">
<div className="min-h-48 bg-background/70 md:col-span-2" />
<div className="min-h-48 bg-background/70" />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,59 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
const THEME_OPTIONS = [
{ label: "Taupe", value: "theme-taupe" },
{ label: "Neutral", value: "theme-neutral" },
{ label: "Stone", value: "theme-stone" },
{ label: "Zinc", value: "theme-zinc" },
{ label: "Mauve", value: "theme-mauve" },
{ label: "Olive", value: "theme-olive" },
{ label: "Mist", value: "theme-mist" },
] as const
const DEFAULT_THEME = "theme-taupe"
function applyThemeToPreviews(theme: string) {
const previewElements = document.querySelectorAll<HTMLElement>(".preview")
previewElements.forEach((element) => {
THEME_OPTIONS.forEach((option) => {
element.classList.remove(option.value)
})
element.classList.add(theme)
})
}
export function ThemeSwitcher() {
const [theme, setTheme] = React.useState<string>(DEFAULT_THEME)
React.useEffect(() => {
applyThemeToPreviews(theme)
}, [theme])
return (
<div className="fixed inset-x-0 bottom-8 z-50 flex justify-center px-4">
<div className="w-full max-w-[60vw] rounded-full border-0 bg-neutral-950/50 p-1.5 shadow-xl backdrop-blur-xl sm:max-w-fit">
<div className="no-scrollbar flex snap-x snap-mandatory items-center overflow-x-auto">
{THEME_OPTIONS.map((option) => (
<button
data-active={theme === option.value}
key={option.value}
type="button"
onClick={() => {
setTheme(option.value)
}}
className="shrink-0 snap-center rounded-full px-3 py-1.5 text-sm font-medium text-neutral-300 outline-hidden transition-colors select-none hover:text-neutral-100 data-active:bg-neutral-500 data-active:text-neutral-100"
>
{option.label}
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,337 @@
"use client"
import {
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
ChevronDownIcon,
Code2Icon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ImageIcon,
ItalicIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
RedoIcon,
StrikethroughIcon,
TypeIcon,
UnderlineIcon,
UndoIcon,
} from "lucide-react"
import { Button } from "@/styles/base-sera/ui/button"
import {
ButtonGroup,
ButtonGroupSeparator,
} from "@/styles/base-sera/ui/button-group"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import { Checkbox } from "@/styles/base-sera/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/base-sera/ui/dropdown-menu"
import {
Field,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/styles/base-sera/ui/field"
import { Input } from "@/styles/base-sera/ui/input"
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/styles/base-sera/ui/progress"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/styles/base-sera/ui/select"
import { Textarea } from "@/styles/base-sera/ui/textarea"
type Milestone = {
name: string
complete: boolean
note?: string
}
const MILESTONES: Milestone[] = [
{
name: "Outline & Commissioning",
complete: true,
},
{
name: "First Draft Submitted",
complete: true,
},
{
name: "Review & Revisions",
complete: false,
note: "Waiting on editor",
},
{
name: "Final Copy Edit",
complete: false,
},
{
name: "Art Direction & Layout",
complete: false,
},
]
const ISSUES = [
{ label: "Spring Issue 2024", value: "spring-2024" },
{ label: "Summer Issue 2024", value: "summer-2024" },
{ label: "Autumn Issue 2024", value: "autumn-2024" },
{ label: "Winter Issue 2024", value: "winter-2024" },
]
export function EditorWorkspace() {
return (
<div className="grid grid-cols-1 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_300px]">
<section className="flex flex-col border border-border/70 bg-background">
<div className="flex border-b p-2">
<ButtonGroup>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="sm">
Normal Text
<ChevronDownIcon data-icon="inline-end" />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuItem>
<TypeIcon />
Normal Text
</DropdownMenuItem>
<DropdownMenuItem>
<Heading1Icon />
Heading 1
</DropdownMenuItem>
<DropdownMenuItem>
<Heading2Icon />
Heading 2
</DropdownMenuItem>
<DropdownMenuItem>
<Heading3Icon />
Heading 3
</DropdownMenuItem>
<DropdownMenuItem>
<ListIcon />
Bullet List
</DropdownMenuItem>
<DropdownMenuItem>
<ListOrderedIcon />
Numbered List
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ButtonGroupSeparator className="mx-2 data-vertical:h-4 data-vertical:self-center" />
<Button variant="ghost" size="icon-sm" aria-label="Bold">
<BoldIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Italic">
<ItalicIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Underline">
<UnderlineIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Strikethrough"
className="hidden md:flex"
>
<StrikethroughIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Code"
className="hidden md:flex"
>
<Code2Icon />
</Button>
<ButtonGroupSeparator className="mx-2 hidden md:flex data-vertical:h-4 data-vertical:self-center" />
<Button
variant="ghost"
size="icon-sm"
aria-label="Align Left"
className="hidden md:flex"
>
<AlignLeftIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Align Center"
className="hidden md:flex"
>
<AlignCenterIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Align Right"
className="hidden md:flex"
>
<AlignRightIcon />
</Button>
<ButtonGroupSeparator className="mx-2 hidden md:flex data-vertical:h-4 data-vertical:self-center" />
<Button
variant="ghost"
size="icon-sm"
aria-label="Link"
className="hidden md:flex"
>
<LinkIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Image"
className="hidden md:flex"
>
<ImageIcon />
</Button>
</ButtonGroup>
<ButtonGroup className="ml-auto">
<Button variant="ghost" size="icon-sm" aria-label="Undo">
<UndoIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Redo">
<RedoIcon />
</Button>
</ButtonGroup>
</div>
<div className="mx-auto flex max-w-2xl flex-1 flex-col gap-8 px-10 py-10 leading-loose md:px-14 lg:py-18">
<h1 className="font-heading text-4xl leading-12 font-medium tracking-wide uppercase">
The Future of Sustainable Architecture
</h1>
<p>
As cities continue to expand at an unprecedented rate, the
architectural paradigm is shifting from mere expansion to
sustainable integration. The concrete jungles of the 20th century
are making way for structures that breathe, adapt, and give back to
their environments.
</p>
<p>
Historically, urban development has been a zero-sum game with
nature.
</p>
<h2 className="font-heading text-2xl tracking-wide uppercase">
The Living Building Challenge
</h2>
<p>
Sterling&apos;s latest project in downtown Seattle is a testament to
this new philosophy. &quot;We are no longer designing static
structures,&quot; Sterling explained during a recent site visit.
&quot;We are engineering localized ecosystems.&quot;
</p>
<p>
The building features a facade of responsive biomaterials that
adjust their porosity based on humidity and temperature,
significantly reducing the need for artificial climate control.
Rainwater is not merely channeled away but captured, filtered
through a series of integrated rooftop wetlands, and reused within
the building&apos;s greywater system.
</p>
<p className="text-sm text-muted-foreground">
This shift requires more than just innovative materials; it demands
a fundamental change in how we value space. Check with engineering
team for specific stats.
</p>
</div>
</section>
<aside className="grid grid-cols-12 gap-(--gap) xl:flex xl:flex-col">
<Card className="col-span-full md:col-span-6 lg:col-span-4">
<CardHeader>
<CardTitle>Article Details</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>Issue</FieldLabel>
<Select items={ISSUES} defaultValue="summer-2024">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ISSUES.map((issue) => (
<SelectItem key={issue.value} value={issue.value}>
{issue.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Author</FieldLabel>
<Input defaultValue="Elena Rostova" />
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card className="col-span-full md:col-span-6 lg:col-span-4">
<CardHeader>
<CardTitle>Publication Flow</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet>
<FieldLegend>Required Milestones</FieldLegend>
<Field>
{MILESTONES.map((milestone) => (
<Field key={milestone.name} orientation="horizontal">
<Checkbox
defaultChecked={milestone.complete}
name={milestone.name}
id={milestone.name}
/>
<FieldLabel htmlFor={milestone.name}>
{milestone.name}
</FieldLabel>
</Field>
))}
</Field>
</FieldSet>
<Field>
<FieldLabel>Add note for editor</FieldLabel>
<Textarea placeholder="This article needs to be revised for clarity and accuracy." />
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card className="col-span-full lg:col-span-4">
<CardHeader>
<CardTitle>Word Count</CardTitle>
</CardHeader>
<CardContent>
<Progress value={70}>
<ProgressLabel>1,402 / 2,000 words</ProgressLabel>
<ProgressValue />
</Progress>
</CardContent>
</Card>
</aside>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react"
import { Badge } from "@/styles/base-sera/ui/badge"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
<ArrowLeftIcon className="size-3.5" />
Back to articles
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
EDIT ARTICLE
</h1>
</div>
<ButtonGroup className="gap-2 md:gap-4">
<Badge title="2 minutes ago">Autosaved</Badge>
<ButtonGroup className="gap-2 md:gap-4">
<Button variant="link">
Preview
<ExternalLinkIcon data-icon="inline-end" />
</Button>
<Button>Submit Draft</Button>
</ButtonGroup>
</ButtonGroup>
</div>
</header>
)
}

View File

@@ -0,0 +1,16 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { EditorWorkspace } from "./components/editor-workspace"
import { PreviewHeader } from "./components/preview-header"
export function EditArticle() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container py-(--gap)">
<EditorWorkspace />
</div>
</div>
)
}

View File

@@ -0,0 +1,95 @@
import { FileTextIcon, PlusIcon } from "lucide-react"
import { Badge } from "@/styles/base-sera/ui/badge"
import { Button } from "@/styles/base-sera/ui/button"
import { Card, CardContent } from "@/styles/base-sera/ui/card"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/styles/base-sera/ui/empty"
import { Separator } from "@/styles/base-sera/ui/separator"
type Stage = {
id: string
label: string
description: string
dotClassName: string
}
const STAGES: Stage[] = [
{
id: "drafting",
label: "Drafting",
description:
"Start the writing process. Articles here are works in progress, visible only to editors and authors.",
dotClassName: "bg-amber-600",
},
{
id: "in-revision",
label: "In Revision",
description:
"Content undergoing editorial review. Track changes and word counts as pieces take shape.",
dotClassName: "bg-orange-700",
},
{
id: "final-edit",
label: "Final Edit",
description:
"The final polish before publication. Ensure all styling and factual checks are complete.",
dotClassName: "bg-foreground",
},
]
export function EmptyDirectory() {
return (
<Card className="py-24">
<CardContent className="flex flex-col items-center gap-10">
<Empty className="min-h-96">
<EmptyHeader>
<EmptyMedia
variant="icon"
className="size-14 rounded-full bg-muted/70 text-muted-foreground"
>
<FileTextIcon className="size-5" />
</EmptyMedia>
<EmptyTitle className="font-heading text-2xl tracking-normal normal-case">
A Blank Canvas
</EmptyTitle>
<EmptyDescription>
Your editorial directory is currently empty. Start building your
publication&apos;s next issue by drafting the first piece.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>
<PlusIcon data-icon="inline-start" />
Create first article
</Button>
</EmptyContent>
</Empty>
<Separator className="max-w-2xl" />
<div className="grid w-full max-w-2xl grid-cols-1 gap-8 sm:grid-cols-3">
{STAGES.map((stage) => (
<div key={stage.id} className="flex flex-col gap-2">
<Badge>
<span
aria-hidden
className={`size-1.5 rounded-full ${stage.dotClassName}`}
/>
{stage.label}
</Badge>
<p className="text-xs leading-relaxed text-muted-foreground">
{stage.description}
</p>
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,37 @@
import { ArrowLeftIcon, PlusIcon } from "lucide-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
<ArrowLeftIcon className="size-3.5" />
Editorial Dashboard
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Article Directory
</h1>
</div>
<Button className="sm:ml-auto">
<PlusIcon data-icon="inline-start" />
New Article
</Button>
</div>
</header>
)
}

View File

@@ -0,0 +1,16 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { EmptyDirectory } from "./components/empty-directory"
import { PreviewHeader } from "./components/preview-header"
export function EmptyState() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container py-(--gap)">
<EmptyDirectory />
</div>
</div>
)
}

View File

@@ -0,0 +1,211 @@
"use client"
import * as React from "react"
import {
FileTextIcon,
ImageIcon,
MoreVerticalIcon,
SearchIcon,
VideoIcon,
} from "lucide-react"
import { Badge } from "@/styles/base-sera/ui/badge"
import { Button } from "@/styles/base-sera/ui/button"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/styles/base-sera/ui/card"
import { Checkbox } from "@/styles/base-sera/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/styles/base-sera/ui/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/styles/base-sera/ui/input-group"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/styles/base-sera/ui/pagination"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/styles/base-sera/ui/table"
import { ASSETS, type AssetType } from "../../media-library/data"
function AssetTypeIcon({
type,
className,
}: {
type: AssetType
className?: string
}) {
if (type === "MP4") {
return <VideoIcon className={className} />
}
if (type === "PDF") {
return <FileTextIcon className={className} />
}
return <ImageIcon className={className} />
}
export function AssetTable() {
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(
new Set(["1"])
)
const toggleSelection = React.useCallback((id: string) => {
setSelectedIds((previous) => {
const next = new Set(previous)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
return (
<Card>
<CardHeader>
<InputGroup className="w-full">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput placeholder="Search files, tags, or metadata..." />
</InputGroup>
</CardHeader>
<CardContent className="px-0 py-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10 pl-6" aria-label="Select" />
<TableHead className="w-20" aria-label="Preview" />
<TableHead>Filename</TableHead>
<TableHead>Type</TableHead>
<TableHead>Dimensions</TableHead>
<TableHead>Size</TableHead>
<TableHead>Uploaded By</TableHead>
<TableHead>Date</TableHead>
<TableHead className="w-10 pr-6" aria-label="Actions" />
</TableRow>
</TableHeader>
<TableBody>
{ASSETS.map((asset) => {
const isSelected = selectedIds.has(asset.id)
return (
<TableRow
key={asset.id}
data-state={isSelected ? "selected" : undefined}
className="cursor-pointer"
onClick={() => toggleSelection(asset.id)}
>
<TableCell className="pl-6">
<Checkbox
checked={isSelected}
aria-label={`Select ${asset.name}`}
onClick={(event) => event.stopPropagation()}
onCheckedChange={() => toggleSelection(asset.id)}
/>
</TableCell>
<TableCell>
<div className="relative flex aspect-4/3 w-16 items-center justify-center bg-muted/60 ring-1 ring-border/70 ring-inset">
{asset.duration ? (
<span className="absolute right-1 bottom-1 bg-foreground/90 px-1 text-[0.5rem] font-semibold tracking-wider text-background">
{asset.duration}
</span>
) : null}
<AssetTypeIcon
type={asset.type}
className="size-4 text-muted-foreground/60"
/>
</div>
</TableCell>
<TableCell className="text-sm font-medium text-foreground">
{asset.name}
</TableCell>
<TableCell>
<Badge
variant="outline"
className="border px-2 py-0.5 text-[0.625rem]"
>
{asset.type}
</Badge>
</TableCell>
<TableCell className="text-sm">{asset.dimensions}</TableCell>
<TableCell className="text-sm">{asset.size}</TableCell>
<TableCell>{asset.uploadedBy}</TableCell>
<TableCell className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
{asset.date}
</TableCell>
<TableCell className="pr-6 text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-xs" />}
aria-label={`Open actions for ${asset.name}`}
onClick={(event) => event.stopPropagation()}
>
<MoreVerticalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Preview</DropdownMenuItem>
<DropdownMenuItem>Download</DropdownMenuItem>
<DropdownMenuItem variant="destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
<CardFooter className="justify-center py-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" text="" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" isActive>
1
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">2</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">3</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="#" text="" />
</PaginationItem>
</PaginationContent>
</Pagination>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,233 @@
"use client"
import * as React from "react"
import { addDays, format } from "date-fns"
import { CalendarIcon, FilterIcon, XIcon } from "lucide-react"
import { type DateRange } from "react-day-picker"
import { Button } from "@/styles/base-sera/ui/button"
import { Calendar } from "@/styles/base-sera/ui/calendar"
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/styles/base-sera/ui/card"
import { Checkbox } from "@/styles/base-sera/ui/checkbox"
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/styles/base-sera/ui/combobox"
import {
Field,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/styles/base-sera/ui/field"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/base-sera/ui/popover"
import { RadioGroup, RadioGroupItem } from "@/styles/base-sera/ui/radio-group"
import { Slider } from "@/styles/base-sera/ui/slider"
const FILE_TYPES = [
{
id: "images",
label: "Images (JPEG, PNG, WEBP)",
defaultChecked: true,
},
{
id: "video",
label: "Video (MP4, MOV)",
defaultChecked: false,
},
{
id: "documents",
label: "Documents (PDF)",
defaultChecked: false,
},
{
id: "audio",
label: "Audio (MP3, WAV)",
defaultChecked: false,
},
]
const DATE_OPTIONS = [
{ value: "any", label: "Any time" },
{ value: "24h", label: "Past 24 hours" },
{ value: "week", label: "Past week" },
{ value: "month", label: "Past month" },
]
const TAGS = [
"architecture",
"brutalism",
"ceramics",
"design-week",
"editorial",
"exterior",
"film",
"food",
"furniture",
"interior",
"kyoto",
"minimalism",
"print",
"sustainability",
"summer-issue",
"video",
] as const
export function FilterLibrary() {
const tagAnchor = useComboboxAnchor()
const [dateRange, setDateRange] = React.useState<DateRange | undefined>({
from: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
to: addDays(
new Date(new Date().getFullYear(), new Date().getMonth(), 1),
21
),
})
return (
<Card>
<CardHeader className="border-b">
<CardTitle>Filter Library</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet>
<FieldLegend>File Type</FieldLegend>
<Field>
{FILE_TYPES.map((type) => (
<Field key={type.id} orientation="horizontal">
<Checkbox id={type.id} defaultChecked={type.defaultChecked} />
<FieldLabel htmlFor={type.id}>{type.label}</FieldLabel>
</Field>
))}
</Field>
</FieldSet>
<FieldSet>
<FieldLegend>Date Uploaded</FieldLegend>
<RadioGroup defaultValue="any">
{DATE_OPTIONS.map((option) => (
<Field key={option.value} orientation="horizontal">
<RadioGroupItem
value={option.value}
id={`date-${option.value}`}
/>
<FieldLabel htmlFor={`date-${option.value}`}>
{option.label}
</FieldLabel>
</Field>
))}
</RadioGroup>
</FieldSet>
<Field>
<FieldLabel htmlFor="custom-range">Custom Range</FieldLabel>
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
id="custom-range"
className="w-full"
/>
}
>
<CalendarIcon data-icon="inline-start" />
{dateRange?.from ? (
dateRange.to ? (
<>
{format(dateRange.from, "LLL dd, y")} {" "}
{format(dateRange.to, "LLL dd, y")}
</>
) : (
format(dateRange.from, "LLL dd, y")
)
) : (
<span>Pick a date range</span>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={setDateRange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</Field>
<FieldSet>
<FieldLegend>File Size</FieldLegend>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between text-xs font-medium tracking-wider text-muted-foreground uppercase">
<span>0 MB</span>
<span>500+ MB</span>
</div>
<Slider defaultValue={[0, 60]} max={100} step={1} />
<div className="flex items-center justify-between text-xs font-medium">
<span>Min: 0 MB</span>
<span>Max: 300 MB</span>
</div>
</div>
</FieldSet>
<FieldSet>
<FieldLegend>Tags</FieldLegend>
<Field>
<Combobox
multiple
autoHighlight
items={TAGS}
defaultValue={["architecture", "brutalism"]}
>
<ComboboxChips ref={tagAnchor}>
<ComboboxValue>
{(values) => (
<React.Fragment>
{values.map((value: string) => (
<ComboboxChip key={value}>{value}</ComboboxChip>
))}
<ComboboxChipsInput placeholder="Filter by tag..." />
</React.Fragment>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={tagAnchor}>
<ComboboxEmpty>No tags found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>
</FieldSet>
</FieldGroup>
</CardContent>
<CardFooter className="flex flex-col gap-2 border-t">
<Button className="w-full">Apply Filters</Button>
<Button variant="ghost" className="w-full">
Reset
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,47 @@
import { ArrowLeftIcon, SlidersHorizontalIcon, UploadIcon } from "lucide-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
<ArrowLeftIcon className="size-3.5" />
Asset management
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Media Library
</h1>
</div>
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
<Button
variant="outline"
className="bg-background hover:bg-background/80"
>
<SlidersHorizontalIcon data-icon="inline-start" />
Filters
</Button>
<Button>
<UploadIcon data-icon="inline-start" />
Upload Assets
</Button>
</ButtonGroup>
</div>
</header>
)
}

View File

@@ -0,0 +1,18 @@
import { Separator } from "@/styles/base-sera/ui/separator"
import { AssetTable } from "./components/asset-table"
import { FilterLibrary } from "./components/filter-library"
import { PreviewHeader } from "./components/preview-header"
export function MediaLibraryTable() {
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container grid grid-cols-1 items-start gap-(--gap) py-(--gap) xl:grid-cols-[minmax(0,1fr)_320px]">
<AssetTable />
<FilterLibrary />
</div>
</div>
)
}

View File

@@ -0,0 +1,110 @@
import {
DownloadIcon,
FileTextIcon,
ImageIcon,
PlusIcon,
VideoIcon,
} from "lucide-react"
import { Badge } from "@/styles/base-sera/ui/badge"
import { Button } from "@/styles/base-sera/ui/button"
import { Card, CardContent, CardFooter } from "@/styles/base-sera/ui/card"
import {
Item,
ItemContent,
ItemDescription,
ItemTitle,
} from "@/styles/base-sera/ui/item"
import { Separator } from "@/styles/base-sera/ui/separator"
import { type Asset, type AssetType } from "../data"
const TYPE_LABEL: Record<AssetType, string> = {
JPEG: "Image / JPEG",
PNG: "Image / PNG",
WEBP: "Image / WEBP",
MP4: "Video / MP4",
PDF: "Document / PDF",
}
export function AssetDetails({ asset }: { asset: Asset }) {
return (
<Card className="gap-0">
<CardContent className="flex flex-col gap-6">
<div className="flex aspect-5/4 items-center justify-center bg-muted/60 text-muted-foreground/60 ring-1 ring-border/70 ring-inset">
{asset.type === "MP4" ? (
<VideoIcon className="size-8" />
) : asset.type === "PDF" ? (
<FileTextIcon className="size-8" />
) : (
<ImageIcon className="size-8" />
)}
</div>
<h2 className="line-clamp-2 font-heading text-xl tracking-wide">
{asset.name}
</h2>
<Separator />
<dl className="flex flex-col gap-5 text-sm">
<div className="flex flex-col gap-1.5">
<dt className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
Asset Type
</dt>
<dd>{TYPE_LABEL[asset.type]}</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<dt className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
Dimensions
</dt>
<dd>{asset.dimensions}</dd>
</div>
<div className="flex flex-col gap-1.5">
<dt className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
File Size
</dt>
<dd>{asset.size}</dd>
</div>
</div>
</dl>
<Separator />
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
Tags
</h3>
<Button variant="ghost" size="icon-xs" aria-label="Add tag">
<PlusIcon />
</Button>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{asset.tags.map((tag) => (
<Badge key={tag}>{tag}</Badge>
))}
</div>
</div>
<Separator />
<div className="flex flex-col gap-3">
<h3 className="text-[0.625rem] font-semibold tracking-widest text-muted-foreground uppercase">
Used In
</h3>
<div className="flex flex-col gap-2">
{asset.usedIn.map((usage) => (
<Item key={usage.title} variant="outline">
<ItemContent>
<ItemTitle>{usage.title}</ItemTitle>
<ItemDescription>{usage.role}</ItemDescription>
</ItemContent>
</Item>
))}
</div>
</div>
</CardContent>
<CardFooter className="mt-6 border-t pt-6">
<Button className="w-full">
<DownloadIcon data-icon="inline-start" />
Download
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,160 @@
"use client"
import {
CheckIcon,
FileTextIcon,
ImageIcon,
SearchIcon,
VideoIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/styles/base-sera/ui/badge"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/styles/base-sera/ui/card"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/styles/base-sera/ui/input-group"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/styles/base-sera/ui/pagination"
import { ASSETS, type Asset, type AssetType } from "../data"
function AssetTypeIcon({
type,
className,
}: {
type: AssetType
className?: string
}) {
if (type === "MP4") {
return <VideoIcon className={className} />
}
if (type === "PDF") {
return <FileTextIcon className={className} />
}
return <ImageIcon className={className} />
}
export function AssetGrid({
selectedId,
onSelect,
}: {
selectedId: string
onSelect: (id: string) => void
}) {
return (
<Card>
<CardHeader>
<InputGroup className="w-full">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput placeholder="Search files, tags, or metadata..." />
</InputGroup>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{ASSETS.map((asset) => (
<AssetGridItem
key={asset.id}
asset={asset}
selected={asset.id === selectedId}
onSelect={() => onSelect(asset.id)}
/>
))}
</div>
</CardContent>
<CardFooter className="justify-center">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" isActive>
1
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">2</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">3</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="#" />
</PaginationItem>
</PaginationContent>
</Pagination>
</CardFooter>
</Card>
)
}
function AssetGridItem({
asset,
selected,
onSelect,
}: {
asset: Asset
selected: boolean
onSelect: () => void
}) {
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
className="group flex flex-col gap-2.5 text-left outline-none focus-visible:[&>div:first-child]:ring-2 focus-visible:[&>div:first-child]:ring-ring"
>
<div
className={cn(
"relative flex aspect-4/3 items-center justify-center bg-muted/60 ring-1 ring-border/70 transition-shadow ring-inset group-hover:ring-foreground/40",
selected && "ring-2 ring-foreground group-hover:ring-foreground"
)}
>
{selected ? (
<div className="absolute top-2 left-2 flex size-5 items-center justify-center bg-foreground text-background">
<CheckIcon className="size-3" />
</div>
) : null}
<Badge
variant="outline"
className="absolute top-2 right-2 border bg-background px-2 py-1 text-[0.625rem]"
>
{asset.type}
</Badge>
{asset.duration ? (
<Badge className="absolute bottom-2 left-2 bg-foreground px-2 py-1 text-background">
{asset.duration}
</Badge>
) : null}
<AssetTypeIcon
type={asset.type}
className="size-7 text-muted-foreground/60"
/>
</div>
<div className="flex flex-col gap-0.5 px-0.5">
<p className="line-clamp-1 text-sm font-medium">{asset.name}</p>
<p className="text-[0.625rem] font-semibold tracking-wider text-muted-foreground uppercase">
{asset.date} · {asset.size}
</p>
</div>
</button>
)
}

View File

@@ -0,0 +1,47 @@
import { ArrowLeftIcon, SlidersHorizontalIcon, UploadIcon } from "lucide-react"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from "@/styles/base-sera/ui/breadcrumb"
import { Button } from "@/styles/base-sera/ui/button"
import { ButtonGroup } from "@/styles/base-sera/ui/button-group"
export function PreviewHeader() {
return (
<header>
<div className="container flex flex-col items-center justify-center gap-(--gap) py-(--gap) sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 text-center sm:text-left">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
<ArrowLeftIcon className="size-3.5" />
Asset management
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="line-clamp-1 font-heading text-3xl tracking-wide uppercase md:text-3xl lg:text-4xl">
Media Library
</h1>
</div>
<ButtonGroup className="gap-2 sm:ml-auto md:gap-4">
<Button
variant="outline"
className="bg-background hover:bg-background/80"
>
<SlidersHorizontalIcon data-icon="inline-start" />
Filters
</Button>
<Button>
<UploadIcon data-icon="inline-start" />
Upload Assets
</Button>
</ButtonGroup>
</div>
</header>
)
}

View File

@@ -0,0 +1,188 @@
export type AssetType = "JPEG" | "PNG" | "WEBP" | "MP4" | "PDF"
export type Asset = {
id: string
name: string
date: string
size: string
type: AssetType
dimensions: string
duration?: string
uploadedBy: string
uploadedByInitials: string
uploadedOn: string
tags: string[]
usedIn: { title: string; role: string }[]
}
export const ASSETS: Asset[] = [
{
id: "1",
name: "brutalism-facade-01.jpg",
date: "Oct 24",
size: "4.2 MB",
type: "JPEG",
dimensions: "4000 × 3000",
uploadedBy: "Marcus Chen",
uploadedByInitials: "MC",
uploadedOn: "Oct 24, 2024",
tags: ["architecture", "brutalism", "exterior", "summer-issue"],
usedIn: [
{ title: "Brutalism's Second Act", role: "Cover Image" },
{ title: "Autumn Sartorial Code", role: "Inline Gallery" },
],
},
{
id: "2",
name: "brutalism-interior-raw.jpg",
date: "Oct 24",
size: "3.8 MB",
type: "JPEG",
dimensions: "3800 × 2850",
uploadedBy: "Marcus Chen",
uploadedByInitials: "MC",
uploadedOn: "Oct 24, 2024",
tags: ["architecture", "brutalism", "interior"],
usedIn: [{ title: "Brutalism's Second Act", role: "Inline Gallery" }],
},
{
id: "3",
name: "seattle-living-building-diagram.png",
date: "Oct 22",
size: "1.1 MB",
type: "PNG",
dimensions: "2000 × 1500",
uploadedBy: "Sarah Jenkins",
uploadedByInitials: "SJ",
uploadedOn: "Oct 22, 2024",
tags: ["diagram", "sustainability", "seattle"],
usedIn: [
{ title: "The Future of Sustainable Architecture", role: "Diagram" },
],
},
{
id: "4",
name: "interview-sofia-coppola-clip1.mp4",
date: "Oct 18",
size: "45.0 MB",
type: "MP4",
dimensions: "1920 × 1080",
duration: "0:45",
uploadedBy: "Emma Ross",
uploadedByInitials: "ER",
uploadedOn: "Oct 18, 2024",
tags: ["video", "interview", "film"],
usedIn: [{ title: "The Aesthetics of Isolation", role: "Featured Video" }],
},
{
id: "5",
name: "kyoto-kilns-pottery-detail.jpg",
date: "Oct 15",
size: "5.6 MB",
type: "JPEG",
dimensions: "4500 × 3000",
uploadedBy: "Marcus Chen",
uploadedByInitials: "MC",
uploadedOn: "Oct 15, 2024",
tags: ["ceramics", "kyoto", "craft"],
usedIn: [{ title: "Kyoto's Oldest Kilns", role: "Hero Image" }],
},
{
id: "6",
name: "copenhagen-design-week-street.jpg",
date: "Oct 12",
size: "3.2 MB",
type: "JPEG",
dimensions: "3600 × 2400",
uploadedBy: "Noah Bennett",
uploadedByInitials: "NB",
uploadedOn: "Oct 12, 2024",
tags: ["copenhagen", "design-week", "street"],
usedIn: [{ title: "Field Notes from Copenhagen", role: "Inline Gallery" }],
},
{
id: "7",
name: "minimalist-chair-render.webp",
date: "Oct 10",
size: "0.8 MB",
type: "WEBP",
dimensions: "2400 × 1600",
uploadedBy: "Claire Duval",
uploadedByInitials: "CD",
uploadedOn: "Oct 10, 2024",
tags: ["furniture", "minimalism", "render"],
usedIn: [{ title: "The New Vanguard", role: "Product Shot" }],
},
{
id: "8",
name: "autumn-issue-style-guide.pdf",
date: "Oct 05",
size: "12.4 MB",
type: "PDF",
dimensions: "N/A",
uploadedBy: "Emma Ross",
uploadedByInitials: "ER",
uploadedOn: "Oct 05, 2024",
tags: ["guidelines", "internal", "autumn"],
usedIn: [{ title: "Autumn Issue 2024", role: "Reference" }],
},
{
id: "9",
name: "milan-lighting-studio-visit.jpg",
date: "Oct 09",
size: "6.1 MB",
type: "JPEG",
dimensions: "5200 × 3466",
uploadedBy: "Claire Duval",
uploadedByInitials: "CD",
uploadedOn: "Oct 09, 2024",
tags: ["milan", "lighting", "studio"],
usedIn: [
{ title: "Milan's Most Elusive Lighting Designer", role: "Hero Image" },
],
},
{
id: "10",
name: "lisbon-culinary-scene-raw.webp",
date: "Oct 14",
size: "2.4 MB",
type: "WEBP",
dimensions: "3000 × 2000",
uploadedBy: "Amara Iqbal",
uploadedByInitials: "AI",
uploadedOn: "Oct 14, 2024",
tags: ["lisbon", "food", "editorial"],
usedIn: [
{ title: "Lisbon's Quiet Culinary Renaissance", role: "Inline Gallery" },
],
},
{
id: "11",
name: "print-magazine-covers-mo...",
date: "Sep 26",
size: "8.9 MB",
type: "PNG",
dimensions: "3200 × 2400",
uploadedBy: "Mina Okafor",
uploadedByInitials: "MO",
uploadedOn: "Sep 26, 2024",
tags: ["print", "magazine", "covers"],
usedIn: [{ title: "The Return of Print", role: "Cover Image" }],
},
{
id: "12",
name: "avant-garde-furniture-trailer.mp4",
date: "Sep 30",
size: "78.2 MB",
type: "MP4",
dimensions: "3840 × 2160",
duration: "1:12",
uploadedBy: "Tommy Rhodes",
uploadedByInitials: "TR",
uploadedOn: "Sep 30, 2024",
tags: ["video", "furniture", "trailer"],
usedIn: [
{ title: "Collecting the New Avant-Garde", role: "Featured Video" },
],
},
]

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import { Separator } from "@/styles/base-sera/ui/separator"
import { AssetDetails } from "./components/asset-details"
import { AssetGrid } from "./components/asset-grid"
import { PreviewHeader } from "./components/preview-header"
import { ASSETS } from "./data"
export function MediaLibrary() {
const [selectedId, setSelectedId] = React.useState<string>(ASSETS[0].id)
const selectedAsset = React.useMemo(
() => ASSETS.find((asset) => asset.id === selectedId) ?? ASSETS[0],
[selectedId]
)
return (
<div className="preview theme-taupe @container/preview w-full flex-1 bg-muted pt-4 font-sans ring-1 ring-foreground/5 [--gap:--spacing(4)] sm:pt-0 md:[--gap:--spacing(6)] xl:[--gap:--spacing(8)] 2xl:py-8 **:[.container]:px-(--gap)">
<PreviewHeader />
<Separator className="hidden sm:block" />
<div className="container grid grid-cols-1 items-start gap-(--gap) py-(--gap) xl:grid-cols-[minmax(0,1fr)_320px]">
<AssetGrid selectedId={selectedId} onSelect={setSelectedId} />
<AssetDetails asset={selectedAsset} />
</div>
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -0,0 +1,72 @@
import { type Metadata } from "next"
import Link from "next/link"
import {
PageActions,
PageHeader,
PageHeaderDescription,
PageHeaderHeading,
} from "@/components/page-header"
import { Button } from "@/styles/radix-sera/ui/button"
import { AudienceAnalytics } from "./audience-analytics"
import { LazyPreview } from "./components/lazy-preview"
import "./style.css"
import { ArrowRightIcon } from "lucide-react"
import { ImagePreview } from "./components/image-preview"
const title = "Introducing Sera"
const description =
"Minimal. Editorial. Typographic. Underline Controls and Uppercase Headings. Shaped by Print Design Principles."
export const metadata: Metadata = {
title,
description,
openGraph: {
title,
description,
},
twitter: {
card: "summary_large_image",
title,
description,
},
}
export default function SeraPage() {
return (
<>
<PageHeader>
<PageHeaderHeading className="font-(family-name:--font-playfair-display) text-[2.875rem] tracking-tight!">
{title}
</PageHeaderHeading>
<PageHeaderDescription className="max-w-2xl text-pretty md:text-balance">
{description}
</PageHeaderDescription>
<PageActions className="**:[.container]:justify-start">
<Button asChild size="sm">
<Link href="/create?preset=b4xFeBLg4O">
Open in shadcn/create
<ArrowRightIcon data-icon="inline-end" />
</Link>
</Button>
</PageActions>
</PageHeader>
<ImagePreview />
<div className="container-wrapper hidden flex-1 flex-col section-soft px-0 md:flex md:px-2 md:py-12">
<div className="container flex flex-1 flex-col gap-10 px-0 3xl:max-w-[2000px] md:px-6">
<AudienceAnalytics />
<LazyPreview name="articleDirectory" />
<LazyPreview name="emptyState" />
<LazyPreview name="editArticle" />
<LazyPreview name="mediaLibrary" />
<LazyPreview name="mediaLibraryTable" />
</div>
</div>
{/* <ThemeSwitcher /> */}
</>
)
}

View File

@@ -0,0 +1,495 @@
@layer base {
.preview {
--font-sans: var(--font-noto-sans);
--font-heading: var(--font-playfair-display);
contain-intrinsic-size: auto 900px;
content-visibility: auto;
}
.theme-taupe {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.3);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.3);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.3);
--primary: oklch(0.214 0.009 43.1);
--primary-foreground: oklch(0.986 0.002 67.8);
--secondary: oklch(0.96 0.002 17.2);
--secondary-foreground: oklch(0.214 0.009 43.1);
--muted: oklch(0.96 0.002 17.2);
--muted-foreground: oklch(0.547 0.021 43.1);
--accent: oklch(0.96 0.002 17.2);
--accent-foreground: oklch(0.214 0.009 43.1);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.005 34.3);
--input: oklch(0.922 0.005 34.3);
--ring: oklch(0.714 0.014 41.2);
--chart-1: oklch(0.868 0.007 39.5);
--chart-2: oklch(0.547 0.021 43.1);
--chart-3: oklch(0.438 0.017 39.3);
--chart-4: oklch(0.367 0.016 35.7);
--chart-5: oklch(0.268 0.011 36.5);
--sidebar: oklch(0.986 0.002 67.8);
--sidebar-foreground: oklch(0.147 0.004 49.3);
--sidebar-primary: oklch(0.214 0.009 43.1);
--sidebar-primary-foreground: oklch(0.986 0.002 67.8);
--sidebar-accent: oklch(0.96 0.002 17.2);
--sidebar-accent-foreground: oklch(0.214 0.009 43.1);
--sidebar-border: oklch(0.922 0.005 34.3);
--sidebar-ring: oklch(0.714 0.014 41.2);
.dark & {
--background: oklch(0.147 0.004 49.3);
--foreground: oklch(0.986 0.002 67.8);
--card: oklch(0.214 0.009 43.1);
--card-foreground: oklch(0.986 0.002 67.8);
--popover: oklch(0.214 0.009 43.1);
--popover-foreground: oklch(0.986 0.002 67.8);
--primary: oklch(0.922 0.005 34.3);
--primary-foreground: oklch(0.214 0.009 43.1);
--secondary: oklch(0.268 0.011 36.5);
--secondary-foreground: oklch(0.986 0.002 67.8);
--muted: oklch(0.268 0.011 36.5);
--muted-foreground: oklch(0.714 0.014 41.2);
--accent: oklch(0.268 0.011 36.5);
--accent-foreground: oklch(0.986 0.002 67.8);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.547 0.021 43.1);
--chart-1: oklch(0.868 0.007 39.5);
--chart-2: oklch(0.547 0.021 43.1);
--chart-3: oklch(0.438 0.017 39.3);
--chart-4: oklch(0.367 0.016 35.7);
--chart-5: oklch(0.268 0.011 36.5);
--sidebar: oklch(0.214 0.009 43.1);
--sidebar-foreground: oklch(0.986 0.002 67.8);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.986 0.002 67.8);
--sidebar-accent: oklch(0.268 0.011 36.5);
--sidebar-accent-foreground: oklch(0.986 0.002 67.8);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.547 0.021 43.1);
}
}
.theme-neutral {
--radius: 0;
--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.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--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.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--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);
.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.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--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.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--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);
}
}
.theme-stone {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.216 0.006 56.043);
--primary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.97 0.001 106.424);
--secondary-foreground: oklch(0.216 0.006 56.043);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--chart-1: oklch(0.869 0.005 56.366);
--chart-2: oklch(0.553 0.013 58.071);
--chart-3: oklch(0.444 0.011 73.639);
--chart-4: oklch(0.374 0.01 67.558);
--chart-5: oklch(0.268 0.007 34.298);
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.216 0.006 56.043);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
.dark & {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.923 0.003 48.717);
--primary-foreground: oklch(0.216 0.006 56.043);
--secondary: oklch(0.268 0.007 34.298);
--secondary-foreground: oklch(0.985 0.001 106.423);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071);
--chart-1: oklch(0.869 0.005 56.366);
--chart-2: oklch(0.553 0.013 58.071);
--chart-3: oklch(0.444 0.011 73.639);
--chart-4: oklch(0.374 0.01 67.558);
--chart-5: oklch(0.268 0.007 34.298);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071);
}
}
.theme-zinc {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
.dark & {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--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.552 0.016 285.938);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
}
.theme-mauve {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0.008 326);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0.008 326);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0.008 326);
--primary: oklch(0.212 0.019 322.12);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.96 0.003 325.6);
--secondary-foreground: oklch(0.212 0.019 322.12);
--muted: oklch(0.96 0.003 325.6);
--muted-foreground: oklch(0.542 0.034 322.5);
--accent: oklch(0.96 0.003 325.6);
--accent-foreground: oklch(0.212 0.019 322.12);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.005 325.62);
--input: oklch(0.922 0.005 325.62);
--ring: oklch(0.711 0.019 323.02);
--chart-1: oklch(0.865 0.012 325.68);
--chart-2: oklch(0.542 0.034 322.5);
--chart-3: oklch(0.435 0.029 321.78);
--chart-4: oklch(0.364 0.029 323.89);
--chart-5: oklch(0.263 0.024 320.12);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0.008 326);
--sidebar-primary: oklch(0.212 0.019 322.12);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.96 0.003 325.6);
--sidebar-accent-foreground: oklch(0.212 0.019 322.12);
--sidebar-border: oklch(0.922 0.005 325.62);
--sidebar-ring: oklch(0.711 0.019 323.02);
.dark & {
--background: oklch(0.145 0.008 326);
--foreground: oklch(0.985 0 0);
--card: oklch(0.212 0.019 322.12);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.212 0.019 322.12);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0.005 325.62);
--primary-foreground: oklch(0.212 0.019 322.12);
--secondary: oklch(0.263 0.024 320.12);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.263 0.024 320.12);
--muted-foreground: oklch(0.711 0.019 323.02);
--accent: oklch(0.263 0.024 320.12);
--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.542 0.034 322.5);
--chart-1: oklch(0.865 0.012 325.68);
--chart-2: oklch(0.542 0.034 322.5);
--chart-3: oklch(0.435 0.029 321.78);
--chart-4: oklch(0.364 0.029 323.89);
--chart-5: oklch(0.263 0.024 320.12);
--sidebar: oklch(0.212 0.019 322.12);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.263 0.024 320.12);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.542 0.034 322.5);
}
}
.theme-olive {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.153 0.006 107.1);
--card: oklch(1 0 0);
--card-foreground: oklch(0.153 0.006 107.1);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.153 0.006 107.1);
--primary: oklch(0.228 0.013 107.4);
--primary-foreground: oklch(0.988 0.003 106.5);
--secondary: oklch(0.966 0.005 106.5);
--secondary-foreground: oklch(0.228 0.013 107.4);
--muted: oklch(0.966 0.005 106.5);
--muted-foreground: oklch(0.58 0.031 107.3);
--accent: oklch(0.966 0.005 106.5);
--accent-foreground: oklch(0.228 0.013 107.4);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.93 0.007 106.5);
--input: oklch(0.93 0.007 106.5);
--ring: oklch(0.737 0.021 106.9);
--chart-1: oklch(0.88 0.011 106.6);
--chart-2: oklch(0.58 0.031 107.3);
--chart-3: oklch(0.466 0.025 107.3);
--chart-4: oklch(0.394 0.023 107.4);
--chart-5: oklch(0.286 0.016 107.4);
--sidebar: oklch(0.988 0.003 106.5);
--sidebar-foreground: oklch(0.153 0.006 107.1);
--sidebar-primary: oklch(0.228 0.013 107.4);
--sidebar-primary-foreground: oklch(0.988 0.003 106.5);
--sidebar-accent: oklch(0.966 0.005 106.5);
--sidebar-accent-foreground: oklch(0.228 0.013 107.4);
--sidebar-border: oklch(0.93 0.007 106.5);
--sidebar-ring: oklch(0.737 0.021 106.9);
.dark & {
--background: oklch(0.153 0.006 107.1);
--foreground: oklch(0.988 0.003 106.5);
--card: oklch(0.228 0.013 107.4);
--card-foreground: oklch(0.988 0.003 106.5);
--popover: oklch(0.228 0.013 107.4);
--popover-foreground: oklch(0.988 0.003 106.5);
--primary: oklch(0.93 0.007 106.5);
--primary-foreground: oklch(0.228 0.013 107.4);
--secondary: oklch(0.286 0.016 107.4);
--secondary-foreground: oklch(0.988 0.003 106.5);
--muted: oklch(0.286 0.016 107.4);
--muted-foreground: oklch(0.737 0.021 106.9);
--accent: oklch(0.286 0.016 107.4);
--accent-foreground: oklch(0.988 0.003 106.5);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.58 0.031 107.3);
--chart-1: oklch(0.88 0.011 106.6);
--chart-2: oklch(0.58 0.031 107.3);
--chart-3: oklch(0.466 0.025 107.3);
--chart-4: oklch(0.394 0.023 107.4);
--chart-5: oklch(0.286 0.016 107.4);
--sidebar: oklch(0.228 0.013 107.4);
--sidebar-foreground: oklch(0.988 0.003 106.5);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.988 0.003 106.5);
--sidebar-accent: oklch(0.286 0.016 107.4);
--sidebar-accent-foreground: oklch(0.988 0.003 106.5);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.58 0.031 107.3);
}
}
.theme-mist {
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.148 0.004 228.8);
--card: oklch(1 0 0);
--card-foreground: oklch(0.148 0.004 228.8);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.148 0.004 228.8);
--primary: oklch(0.218 0.008 223.9);
--primary-foreground: oklch(0.987 0.002 197.1);
--secondary: oklch(0.963 0.002 197.1);
--secondary-foreground: oklch(0.218 0.008 223.9);
--muted: oklch(0.963 0.002 197.1);
--muted-foreground: oklch(0.56 0.021 213.5);
--accent: oklch(0.963 0.002 197.1);
--accent-foreground: oklch(0.218 0.008 223.9);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.925 0.005 214.3);
--input: oklch(0.925 0.005 214.3);
--ring: oklch(0.723 0.014 214.4);
--chart-1: oklch(0.872 0.007 219.6);
--chart-2: oklch(0.56 0.021 213.5);
--chart-3: oklch(0.45 0.017 213.2);
--chart-4: oklch(0.378 0.015 216);
--chart-5: oklch(0.275 0.011 216.9);
--sidebar: oklch(0.987 0.002 197.1);
--sidebar-foreground: oklch(0.148 0.004 228.8);
--sidebar-primary: oklch(0.218 0.008 223.9);
--sidebar-primary-foreground: oklch(0.987 0.002 197.1);
--sidebar-accent: oklch(0.963 0.002 197.1);
--sidebar-accent-foreground: oklch(0.218 0.008 223.9);
--sidebar-border: oklch(0.925 0.005 214.3);
--sidebar-ring: oklch(0.723 0.014 214.4);
.dark & {
--background: oklch(0.148 0.004 228.8);
--foreground: oklch(0.987 0.002 197.1);
--card: oklch(0.218 0.008 223.9);
--card-foreground: oklch(0.987 0.002 197.1);
--popover: oklch(0.218 0.008 223.9);
--popover-foreground: oklch(0.987 0.002 197.1);
--primary: oklch(0.925 0.005 214.3);
--primary-foreground: oklch(0.218 0.008 223.9);
--secondary: oklch(0.275 0.011 216.9);
--secondary-foreground: oklch(0.987 0.002 197.1);
--muted: oklch(0.275 0.011 216.9);
--muted-foreground: oklch(0.723 0.014 214.4);
--accent: oklch(0.275 0.011 216.9);
--accent-foreground: oklch(0.987 0.002 197.1);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.56 0.021 213.5);
--chart-1: oklch(0.872 0.007 219.6);
--chart-2: oklch(0.56 0.021 213.5);
--chart-3: oklch(0.45 0.017 213.2);
--chart-4: oklch(0.378 0.015 216);
--chart-5: oklch(0.275 0.011 216.9);
--sidebar: oklch(0.218 0.008 223.9);
--sidebar-foreground: oklch(0.987 0.002 197.1);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.987 0.002 197.1);
--sidebar-accent: oklch(0.275 0.011 216.9);
--sidebar-accent-foreground: oklch(0.987 0.002 197.1);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.56 0.021 213.5);
}
}
}
@utility font-heading {
font-family: var(--font-serif);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -10,6 +10,7 @@ import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
const presetCode = usePresetCode()
const [hasCopied, setHasCopied] = React.useState(false)
const label = hasCopied ? "Copied" : `--preset ${presetCode}`
React.useEffect(() => {
if (hasCopied) {
@@ -32,12 +33,13 @@ export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
<Button
variant="outline"
onClick={handleCopy}
title={label}
className={cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)}
>
<span>{hasCopied ? "Copied" : `--preset ${presetCode}`}</span>
<span className="block min-w-0 truncate">{label}</span>
</Button>
)
}

View File

@@ -23,12 +23,12 @@ import { FontPicker } from "@/app/(app)/create/components/font-picker"
import { IconLibraryPicker } from "@/app/(app)/create/components/icon-library-picker"
import { MainMenu } from "@/app/(app)/create/components/main-menu"
import { MenuColorPicker } from "@/app/(app)/create/components/menu-picker"
import { OpenPreset } from "@/app/(app)/create/components/open-preset"
import { RadiusPicker } from "@/app/(app)/create/components/radius-picker"
import { RandomButton } from "@/app/(app)/create/components/random-button"
import { ResetDialog } from "@/app/(app)/create/components/reset-button"
import { StylePicker } from "@/app/(app)/create/components/style-picker"
import { ThemePicker } from "@/app/(app)/create/components/theme-picker"
import { V0Button } from "@/app/(app)/create/components/v0-button"
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
@@ -102,8 +102,12 @@ export function Customizer({
</FieldGroup>
</CardContent>
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:rounded-b-none md:**:[button,a]:w-full">
<CopyPreset className="flex-1 md:flex-none" />
<RandomButton className="flex-1 md:flex-none" />
<CopyPreset className="min-w-0 flex-1 md:flex-none" />
<OpenPreset
className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none"
label={isMobile ? "Open" : "Open Preset"}
/>
<RandomButton className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none" />
<ActionMenu itemsByBase={itemsByBase} />
<ResetDialog />
</CardFooter>

View File

@@ -5,6 +5,7 @@ import * as React from "react"
import {
buildRegistryTheme,
DEFAULT_CONFIG,
POINTER_CURSOR_SELECTOR,
type DesignSystemConfig,
} from "@/registry/config"
import { useIframeMessageListener } from "@/app/(app)/create/hooks/use-iframe-sync"
@@ -16,6 +17,12 @@ import {
const THEME_STYLE_ELEMENT_ID = "design-system-theme-vars"
const MANAGED_BODY_CLASS_PREFIXES = ["style-", "base-color-"] as const
const POINTER_CURSOR_CSS = `@layer base {
${POINTER_CURSOR_SELECTOR} {
cursor: pointer;
}
}
`
type RegistryThemeCssVars = NonNullable<
ReturnType<typeof buildRegistryTheme>["cssVars"]
@@ -44,14 +51,17 @@ function buildCssRule(selector: string, cssVars?: Record<string, string>) {
return `${selector} {\n${declarations}\n}\n`
}
function buildThemeCssText(cssVars: RegistryThemeCssVars) {
function buildThemeCssText(cssVars: RegistryThemeCssVars, pointer: boolean) {
return [
buildCssRule(":root", {
...(cssVars.theme ?? {}),
...(cssVars.light ?? {}),
}),
buildCssRule(".dark", cssVars.dark),
].join("\n")
pointer ? POINTER_CURSOR_CSS : "",
]
.filter(Boolean)
.join("\n")
}
export function DesignSystemProvider({
@@ -73,6 +83,7 @@ export function DesignSystemProvider({
chartColor,
menuAccent,
menuColor,
pointer,
radius,
} = searchParams
const effectiveRadius = style === "lyra" ? "none" : radius
@@ -130,7 +141,7 @@ export function DesignSystemProvider({
useIframeMessageListener("design-system-params", handleDesignSystemMessage)
React.useEffect(() => {
if (style === "lyra" && radius !== "none") {
if (style === "lyra" || (style === "sera" && radius !== "none")) {
setSearchParams({ radius: "none" })
}
}, [style, radius, setSearchParams])
@@ -208,8 +219,8 @@ export function DesignSystemProvider({
document.head.appendChild(styleElement)
}
styleElement.textContent = buildThemeCssText(registryTheme.cssVars)
}, [registryTheme])
styleElement.textContent = buildThemeCssText(registryTheme.cssVars, pointer)
}, [registryTheme, pointer])
// Handle menu color inversion by adding/removing dark class to elements with cn-menu-target.
// useLayoutEffect to apply classes synchronously before paint, avoiding flash.

View File

@@ -17,6 +17,7 @@ import {
} from "@/app/(app)/create/components/picker"
import { useActionMenuTrigger } from "@/app/(app)/create/hooks/use-action-menu"
import { useHistory } from "@/app/(app)/create/hooks/use-history"
import { useOpenPresetTrigger } from "@/app/(app)/create/hooks/use-open-preset"
import { useRandom } from "@/app/(app)/create/hooks/use-random"
import { useReset } from "@/app/(app)/create/hooks/use-reset"
import { useThemeToggle } from "@/app/(app)/create/hooks/use-theme-toggle"
@@ -27,6 +28,7 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
const [isMac, setIsMac] = React.useState(false)
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
const { openActionMenu } = useActionMenuTrigger()
const { openPreset } = useOpenPresetTrigger()
const { randomize } = useRandom()
const { toggleTheme } = useThemeToggle()
const { setShowResetDialog } = useReset()
@@ -55,6 +57,9 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
Navigate...
<PickerShortcut>{isMac ? "⌘P" : "Ctrl+P"}</PickerShortcut>
</PickerItem>
<PickerItem onClick={openPreset}>
Open Preset... <PickerShortcut>O</PickerShortcut>
</PickerItem>
<PickerItem onClick={randomize}>
Shuffle <PickerShortcut>R</PickerShortcut>
</PickerItem>

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import Script from "next/script"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/styles/base-nova/ui/dialog"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-nova/ui/drawer"
import { Field, FieldContent, FieldLabel } from "@/styles/base-nova/ui/field"
import { Input } from "@/styles/base-nova/ui/input"
import {
OPEN_PRESET_FORWARD_TYPE,
useOpenPreset,
} from "@/app/(app)/create/hooks/use-open-preset"
import { parsePresetInput } from "@/app/(app)/create/lib/parse-preset-input"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
const PRESET_EXAMPLE = "b2D0wqNxT"
const PRESET_TITLE = "Open Preset"
const PRESET_DESCRIPTION = "Paste a preset code to load a saved configuration."
export function OpenPreset({
className,
label = "Open Preset",
}: React.ComponentProps<typeof Button> & {
label?: string
}) {
const [input, setInput] = React.useState("")
const [, setParams] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
const { open, setOpen } = useOpenPreset()
const nextPreset = React.useMemo(() => parsePresetInput(input), [input])
const isInvalid = input.trim().length > 0 && nextPreset === null
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setInput("")
}
},
[setOpen]
)
const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!nextPreset) {
return
}
setParams({ preset: nextPreset })
handleOpenChange(false)
},
[handleOpenChange, nextPreset, setParams]
)
const triggerClassName = cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)
const desktopTrigger = (
<Button variant="outline" className={triggerClassName} />
)
const fields = (
<Field data-invalid={isInvalid || undefined}>
<FieldLabel htmlFor="preset-code" className="sr-only">
Preset code
</FieldLabel>
<FieldContent>
<Input
id="preset-code"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={`${PRESET_EXAMPLE} or --preset ${PRESET_EXAMPLE}`}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
aria-invalid={isInvalid}
className="h-10 md:h-8"
/>
</FieldContent>
</Field>
)
if (isMobile) {
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>
<Button variant="outline" className={triggerClassName}>
{label}
</Button>
</DrawerTrigger>
<DrawerContent className="dark rounded-t-2xl!">
<DrawerHeader>
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
<DrawerDescription>{PRESET_DESCRIPTION}</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit}>
<div className="px-4 py-2">{fields}</div>
<DrawerFooter>
<Button type="submit" className="h-10" disabled={!nextPreset}>
Open
</Button>
<DrawerClose asChild>
<Button variant="outline" type="button" className="h-10">
Cancel
</Button>
</DrawerClose>
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={desktopTrigger}>{label}</DialogTrigger>
<DialogContent className="dark">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{PRESET_TITLE}</DialogTitle>
<DialogDescription>{PRESET_DESCRIPTION}</DialogDescription>
</DialogHeader>
<div className="py-4">{fields}</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" type="button" />}>
Cancel
</DialogClose>
<Button type="submit" disabled={!nextPreset}>
Open
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function OpenPresetScript() {
return (
<Script
id="open-preset-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward O key.
document.addEventListener('keydown', function(e) {
if (e.key === 'o' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: '${OPEN_PRESET_FORWARD_TYPE}',
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -11,6 +11,7 @@ import { DARK_MODE_FORWARD_TYPE } from "@/app/(app)/create/components/mode-switc
import { PreviewSwitcher } from "@/app/(app)/create/components/preview-switcher"
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(app)/create/components/random-button"
import { sendToIframe } from "@/app/(app)/create/hooks/use-iframe-sync"
import { OPEN_PRESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-open-preset"
import { RESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-reset"
import {
serializeDesignSystemSearchParams,
@@ -20,78 +21,6 @@ import {
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
// Hoisted — only uses module-level constants, no component state. (rendering-hoist-jsx)
function handleMessage(event: MessageEvent) {
if (
typeof window === "undefined" ||
event.origin !== window.location.origin
) {
return
}
const type = event.data.type
if (type === CMD_K_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "k",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RANDOMIZE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "r",
bubbles: true,
cancelable: true,
})
)
} else if (type === UNDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === REDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
shiftKey: true,
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "R",
shiftKey: true,
bubbles: true,
cancelable: true,
})
)
} else if (type === DARK_MODE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "d",
bubbles: true,
cancelable: true,
})
)
}
}
export function Preview() {
const [params] = useDesignSystemSearchParams()
const iframeRef = React.useRef<HTMLIFrameElement>(null)
@@ -117,6 +46,89 @@ export function Preview() {
}, [params])
React.useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const iframeWindow = iframeRef.current?.contentWindow
if (
!iframeWindow ||
event.origin !== window.location.origin ||
event.source !== iframeWindow ||
!event.data ||
typeof event.data !== "object"
) {
return
}
const type = event.data.type
if (type === CMD_K_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "k",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RANDOMIZE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "r",
bubbles: true,
cancelable: true,
})
)
} else if (type === OPEN_PRESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "o",
bubbles: true,
cancelable: true,
})
)
} else if (type === UNDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === REDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
shiftKey: true,
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "R",
shiftKey: true,
bubbles: true,
cancelable: true,
})
)
} else if (type === DARK_MODE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "d",
bubbles: true,
cancelable: true,
})
)
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)

View File

@@ -1,26 +1,36 @@
"use client"
import * as React from "react"
import { Copy01Icon, Globe02Icon, Tick02Icon } from "@hugeicons/core-free-icons"
import {
Copy01Icon,
Globe02Icon,
HandPointingRight04Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { cn } from "@/lib/utils"
import { useConfig } from "@/hooks/use-config"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { BASES, type BaseName } from "@/registry/config"
import {
BASES,
buildThemeForPreset,
DEFAULT_CONFIG,
type BaseName,
type DesignSystemConfig,
} from "@/registry/config"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/styles/base-nova/ui/dialog"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
@@ -36,6 +46,10 @@ import {
TabsList,
TabsTrigger,
} from "@/styles/base-nova/ui/tabs"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/styles/base-nova/ui/toggle-group"
import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
import {
useDesignSystemSearchParams,
@@ -56,6 +70,54 @@ const SHADCN_VERSION = process.env.NEXT_PUBLIC_RC ? "@rc" : "@latest"
const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun"] as const
type PackageManager = (typeof PACKAGE_MANAGERS)[number]
const APPLY_MODES = [
{
value: "full",
title: "Full preset",
description:
"Everything from the preset, including components, theme, and fonts.",
flag: null,
label: "full preset",
},
{
value: "theme",
title: "Theme only",
description:
"Theme tokens only, like colors, radii, and shadows. Components stay as they are.",
flag: "--only theme",
label: "--only theme",
},
{
value: "font",
title: "Fonts only",
description:
"Only preset fonts for body and headings. Components stay as they are.",
flag: "--only font",
label: "--only font",
},
] as const
type ApplyMode = (typeof APPLY_MODES)[number]["value"]
type ProjectFormTab = "new-project" | "existing-project" | "theme"
type CopyTarget = "command" | "apply" | "theme"
type ThemeCssVars = NonNullable<
ReturnType<typeof buildThemeForPreset>["cssVars"]
>
function formatCssVarsRule(selector: string, cssVars?: Record<string, string>) {
const declarations = Object.entries(cssVars ?? {})
.map(([key, value]) => ` --${key}: ${value};`)
.join("\n")
return `${selector} {\n${declarations}\n}`
}
function formatThemeCss(cssVars: ThemeCssVars) {
return [
formatCssVarsRule(":root", cssVars.light),
formatCssVarsRule(".dark", cssVars.dark),
].join("\n\n")
}
export function ProjectForm({
className,
}: React.ComponentProps<typeof Button>) {
@@ -63,7 +125,12 @@ export function ProjectForm({
const [params, setParams] = useDesignSystemSearchParams()
const presetCode = usePresetCode()
const [config, setConfig] = useConfig()
const [hasCopied, setHasCopied] = React.useState(false)
const [copiedTarget, setCopiedTarget] = React.useState<CopyTarget | null>(
null
)
const [applyMode, setApplyMode] = React.useState<ApplyMode>("full")
const [activeTab, setActiveTab] =
React.useState<ProjectFormTab>("new-project")
const packageManager = (config.packageManager || "pnpm") as PackageManager
const framework = React.useMemo(
@@ -85,7 +152,8 @@ export function ProjectForm({
const templateFlag = ` --template ${framework}`
const monorepoFlag = isMonorepo ? " --monorepo" : ""
const rtlFlag = params.rtl ? " --rtl" : ""
const flags = `${presetFlag}${baseFlag}${templateFlag}${monorepoFlag}${rtlFlag}`
const pointerFlag = params.pointer ? " --pointer" : ""
const flags = `${presetFlag}${baseFlag}${templateFlag}${monorepoFlag}${rtlFlag}${pointerFlag}`
return IS_LOCAL_DEV
? {
@@ -100,16 +168,96 @@ export function ProjectForm({
yarn: `yarn dlx shadcn${SHADCN_VERSION} init${flags}`,
bun: `bunx --bun shadcn${SHADCN_VERSION} init${flags}`,
}
}, [framework, isMonorepo, params.base, params.rtl, presetCode])
}, [
framework,
isMonorepo,
params.base,
params.pointer,
params.rtl,
presetCode,
])
const command = commands[packageManager]
const applyCommands = React.useMemo(() => {
const presetFlag = ` --preset ${presetCode}`
const onlyFlag =
applyMode === "theme"
? " --only theme"
: applyMode === "font"
? " --only font"
: ""
const flags = `${presetFlag}${onlyFlag}`
return IS_LOCAL_DEV
? {
pnpm: `shadcn apply${flags}`,
npm: `shadcn apply${flags}`,
yarn: `shadcn apply${flags}`,
bun: `shadcn apply${flags}`,
}
: {
pnpm: `pnpm dlx shadcn${SHADCN_VERSION} apply${flags}`,
npm: `npx shadcn${SHADCN_VERSION} apply${flags}`,
yarn: `yarn dlx shadcn${SHADCN_VERSION} apply${flags}`,
bun: `bunx --bun shadcn${SHADCN_VERSION} apply${flags}`,
}
}, [applyMode, presetCode])
const applyCommand = applyCommands[packageManager]
const themeConfig = React.useMemo<DesignSystemConfig>(() => {
const isRadiusLocked = params.style === "lyra" || params.style === "sera"
return {
...DEFAULT_CONFIG,
base: params.base,
style: params.style,
baseColor: params.baseColor,
theme: params.theme,
chartColor: params.chartColor,
iconLibrary: params.iconLibrary,
font: params.font,
fontHeading: params.fontHeading,
menuAccent: params.menuAccent,
menuColor: params.menuColor,
radius: isRadiusLocked ? "none" : params.radius,
template: params.template,
rtl: params.rtl,
pointer: params.pointer,
}
}, [
params.base,
params.baseColor,
params.chartColor,
params.font,
params.fontHeading,
params.iconLibrary,
params.menuAccent,
params.menuColor,
params.pointer,
params.radius,
params.rtl,
params.style,
params.template,
params.theme,
])
const themeCss = React.useMemo(() => {
const theme = buildThemeForPreset(themeConfig)
if (!theme.cssVars) {
return ""
}
return formatThemeCss(theme.cssVars)
}, [themeConfig])
React.useEffect(() => {
if (hasCopied) {
const timer = setTimeout(() => setHasCopied(false), 2000)
if (copiedTarget) {
const timer = setTimeout(() => setCopiedTarget(null), 2000)
return () => clearTimeout(timer)
}
}, [hasCopied])
}, [copiedTarget])
const handleCopy = React.useCallback(() => {
const properties: Record<string, string> = {
@@ -122,142 +270,316 @@ export function ProjectForm({
name: "copy_npm_command",
properties,
})
setHasCopied(true)
setCopiedTarget("command")
}, [command, params.template])
const handleCopyApply = React.useCallback(() => {
copyToClipboardWithMeta(applyCommand, {
name: "copy_apply_command",
properties: {
command: applyCommand,
applyMode,
},
})
setCopiedTarget("apply")
}, [applyCommand, applyMode])
const handleCopyTheme = React.useCallback(() => {
copyToClipboardWithMeta(themeCss, {
name: "copy_theme_code",
properties: {
preset: presetCode,
baseColor: params.baseColor,
theme: params.theme,
format: "css",
},
})
setCopiedTarget("theme")
}, [params.baseColor, params.theme, presetCode, themeCss])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button className={cn(className)} />}>
Create Project
Get Code
</DialogTrigger>
<DialogContent className="dark no-scrollbar max-h-[calc(100svh-2rem)] overflow-y-auto rounded-2xl p-6 shadow-xl **:data-[slot=field-separator]:h-2 sm:max-w-sm">
<DialogHeader>
<DialogTitle>Create Project</DialogTitle>
<DialogDescription>
Pick a template and configure your project.
</DialogDescription>
</DialogHeader>
<div>
<FieldGroup>
<FieldSeparator className="-mx-6" />
<Field className="-mt-2 gap-3">
<FieldLabel>Template</FieldLabel>
<TemplateGrid template={params.template} setParams={setParams} />
</Field>
<FieldSeparator className="-mx-6" />
<Field className="-mt-2">
<FieldLabel>Base</FieldLabel>
<BaseGrid base={params.base} setParams={setParams} />
</Field>
<FieldSeparator className="-mx-6" />
<FieldSet>
<FieldLegend variant="label" className="sr-only">
Options
</FieldLegend>
<Field
orientation="horizontal"
data-disabled={hasMonorepo ? undefined : "true"}
>
<FieldLabel htmlFor="monorepo">
<span
className="size-4 text-neutral-100 [&_svg]:size-4 [&_svg]:fill-current"
dangerouslySetInnerHTML={{
__html: TURBOREPO_LOGO,
}}
/>
Create a monorepo
</FieldLabel>
<Switch
id="monorepo"
checked={params.template?.endsWith("-monorepo") ?? false}
disabled={!hasMonorepo}
onCheckedChange={(checked) => {
const framework = getFramework(params.template ?? "next")
setParams({
template: getTemplateValue(
framework,
checked === true
) as typeof params.template,
})
}}
/>
</Field>
<FieldSeparator className="-mx-6" />
<Field orientation="horizontal">
<FieldLabel htmlFor="rtl">
<HugeiconsIcon icon={Globe02Icon} className="size-4" />
Enable RTL support
</FieldLabel>
<Switch
id="rtl"
checked={params.rtl}
onCheckedChange={(checked) =>
setParams({ rtl: checked === true })
}
/>
</Field>
</FieldSet>
</FieldGroup>
</div>
<DialogFooter className="-mx-6 -mb-6 min-w-0">
<div className="flex w-full min-w-0 flex-col gap-3">
<Tabs
value={packageManager}
onValueChange={(value) => {
setConfig((prev) => ({
...prev,
packageManager: value as PackageManager,
}))
}}
className="min-w-0 gap-0 overflow-hidden rounded-xl border-0 ring-1 ring-border"
<DialogContent className="dark top-[64px] no-scrollbar flex max-h-[calc(100svh-2rem)] translate-y-0 flex-col rounded-2xl p-0 shadow-xl **:data-[slot=dialog-close]:top-4.5 **:data-[slot=dialog-close]:right-4 **:data-[slot=field-separator]:h-2 sm:max-w-md">
<div className="flex min-w-0 flex-1 flex-col gap-0 overflow-hidden rounded-2xl">
<DialogHeader className="border-b px-6 py-5">
<ToggleGroup
value={[activeTab]}
onValueChange={(values) =>
setActiveTab((values[0] as typeof activeTab) ?? "new-project")
}
aria-label="Project type"
spacing={2}
className="**:data-[slot=toggle-group-item]:data-pressed:bg-neutral-700/70"
>
<div className="flex items-center gap-2 py-1 pr-1.5 pl-1">
<TabsList className="bg-transparent font-mono">
{PACKAGE_MANAGERS.map((manager) => {
return (
<TabsTrigger
key={manager}
value={manager}
className="py-0 leading-none data-[state=active]:shadow-none"
<ToggleGroupItem value="new-project">New Project</ToggleGroupItem>
<ToggleGroupItem value="existing-project">
Existing Project
</ToggleGroupItem>
<ToggleGroupItem value="theme">Theme</ToggleGroupItem>
</ToggleGroup>
</DialogHeader>
{activeTab === "new-project" && (
<div className="no-scrollbar overflow-y-auto">
<FieldGroup className="px-6 py-4">
<Field className="gap-3">
<FieldLabel>Template</FieldLabel>
<TemplateGrid
template={params.template}
setParams={setParams}
/>
</Field>
<FieldSeparator className="-mx-6" />
<Field>
<FieldLabel>Base</FieldLabel>
<BaseGrid base={params.base} setParams={setParams} />
</Field>
<FieldSeparator className="-mx-6" />
<FieldSet>
<FieldLegend variant="label" className="sr-only">
Options
</FieldLegend>
<Field orientation="horizontal">
<FieldLabel htmlFor="pointer">
<HugeiconsIcon
icon={HandPointingRight04Icon}
className="size-4 -rotate-90"
/>
Use pointer on buttons
</FieldLabel>
<Switch
id="pointer"
checked={params.pointer}
onCheckedChange={(checked) =>
setParams({ pointer: checked === true })
}
/>
</Field>
<FieldSeparator className="-mx-6" />
<Field
orientation="horizontal"
data-disabled={hasMonorepo ? undefined : "true"}
>
<FieldLabel htmlFor="monorepo">
<span
className="size-4 text-neutral-100 [&_svg]:size-4 [&_svg]:fill-current"
dangerouslySetInnerHTML={{
__html: TURBOREPO_LOGO,
}}
/>
Create a monorepo
</FieldLabel>
<Switch
id="monorepo"
checked={params.template?.endsWith("-monorepo") ?? false}
disabled={!hasMonorepo}
onCheckedChange={(checked) => {
const framework = getFramework(
params.template ?? "next"
)
setParams({
template: getTemplateValue(
framework,
checked === true
) as typeof params.template,
})
}}
/>
</Field>
<FieldSeparator className="-mx-6" />
<Field orientation="horizontal">
<FieldLabel htmlFor="rtl">
<HugeiconsIcon icon={Globe02Icon} className="size-4" />
Enable RTL support
</FieldLabel>
<Switch
id="rtl"
checked={params.rtl}
onCheckedChange={(checked) =>
setParams({ rtl: checked === true })
}
/>
</Field>
</FieldSet>
</FieldGroup>
<DialogFooter className="m-0 min-w-0 p-6">
<div className="flex w-full min-w-0 flex-col gap-3">
<Tabs
value={packageManager}
onValueChange={(value) => {
setConfig((prev) => ({
...prev,
packageManager: value as PackageManager,
}))
}}
className="min-w-0 gap-0 overflow-hidden rounded-xl border-0 ring-1 ring-border"
>
<div className="flex items-center gap-2 py-1 pr-1.5 pl-1">
<TabsList className="bg-transparent font-mono">
{PACKAGE_MANAGERS.map((manager) => {
return (
<TabsTrigger
key={manager}
value={manager}
className="py-0 leading-none data-[state=active]:shadow-none"
>
{manager}
</TabsTrigger>
)
})}
</TabsList>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopy}
>
{manager}
</TabsTrigger>
)
})}
</TabsList>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopy}
>
{hasCopied ? (
<HugeiconsIcon icon={Tick02Icon} />
) : (
<HugeiconsIcon icon={Copy01Icon} />
)}
<span className="sr-only">Copy command</span>
</Button>
</div>
{Object.entries(commands).map(([key, cmd]) => {
return (
<TabsContent key={key} value={key}>
<div className="relative overflow-hidden border-t bg-popover p-3">
<div className="no-scrollbar overflow-x-auto">
<code className="font-mono text-sm whitespace-nowrap">
{cmd}
</code>
</div>
{copiedTarget === "command" ? (
<HugeiconsIcon icon={Tick02Icon} />
) : (
<HugeiconsIcon icon={Copy01Icon} />
)}
<span className="sr-only">Copy command</span>
</Button>
</div>
</TabsContent>
)
})}
</Tabs>
<Button onClick={handleCopy} className="h-9 w-full">
{hasCopied ? "Copied" : "Copy Command"}
</Button>
</div>
</DialogFooter>
{Object.entries(commands).map(([key, cmd]) => {
return (
<TabsContent key={key} value={key}>
<div className="relative overflow-hidden border-t bg-popover p-3">
<div className="no-scrollbar overflow-x-auto">
<code className="font-mono text-sm whitespace-nowrap">
{cmd}
</code>
</div>
</div>
</TabsContent>
)
})}
</Tabs>
<Button onClick={handleCopy} className="h-9 w-full">
{copiedTarget === "command" ? "Copied" : "Copy Command"}
</Button>
</div>
</DialogFooter>
</div>
)}
{activeTab === "existing-project" && (
<div className="no-scrollbar overflow-y-auto">
<FieldGroup className="px-6 py-4">
<FieldSet className="gap-3">
<FieldLegend variant="label">Apply Preset</FieldLegend>
<FieldDescription>
Pick which parts of the preset to apply.
</FieldDescription>
<ApplyModeGrid mode={applyMode} setMode={setApplyMode} />
</FieldSet>
</FieldGroup>
<DialogFooter className="m-0 min-w-0 p-6">
<div className="flex w-full min-w-0 flex-col gap-3">
<Tabs
value={packageManager}
onValueChange={(value) => {
setConfig((prev) => ({
...prev,
packageManager: value as PackageManager,
}))
}}
className="min-w-0 gap-0 overflow-hidden rounded-xl border-0 ring-1 ring-border"
>
<div className="flex items-center gap-2 py-1 pr-1.5 pl-1">
<TabsList className="bg-transparent font-mono">
{PACKAGE_MANAGERS.map((manager) => {
return (
<TabsTrigger
key={manager}
value={manager}
className="py-0 leading-none data-[state=active]:shadow-none"
>
{manager}
</TabsTrigger>
)
})}
</TabsList>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopyApply}
>
{copiedTarget === "apply" ? (
<HugeiconsIcon icon={Tick02Icon} />
) : (
<HugeiconsIcon icon={Copy01Icon} />
)}
<span className="sr-only">Copy command</span>
</Button>
</div>
{Object.entries(applyCommands).map(([key, cmd]) => {
return (
<TabsContent key={key} value={key}>
<div className="relative overflow-hidden border-t bg-popover p-3">
<div className="no-scrollbar overflow-x-auto">
<code className="font-mono text-sm whitespace-nowrap">
{cmd}
</code>
</div>
</div>
</TabsContent>
)
})}
</Tabs>
<Button onClick={handleCopyApply} className="h-9 w-full">
{copiedTarget === "apply" ? "Copied" : "Copy Command"}
</Button>
</div>
</DialogFooter>
</div>
)}
{activeTab === "theme" && (
<div className="no-scrollbar overflow-y-auto">
<FieldGroup className="min-w-0 px-6 py-4">
<FieldSet className="min-w-0 gap-3">
<FieldLegend variant="label">Theme Tokens</FieldLegend>
<FieldDescription>
Copy the CSS variables for this preset.
</FieldDescription>
<div className="w-full min-w-0 overflow-hidden rounded-xl border-0 ring-1 ring-border">
<div className="flex items-center gap-2 py-1 pr-1.5 pl-3">
<div className="min-w-0 truncate font-mono text-sm text-muted-foreground">
globals.css
</div>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopyTheme}
>
{copiedTarget === "theme" ? (
<HugeiconsIcon icon={Tick02Icon} />
) : (
<HugeiconsIcon icon={Copy01Icon} />
)}
<span className="sr-only">Copy theme</span>
</Button>
</div>
<div className="relative no-scrollbar max-h-[45svh] overflow-auto border-t bg-popover p-3">
<pre className="min-w-max font-mono leading-normal whitespace-pre">
<code>{themeCss}</code>
</pre>
</div>
</div>
</FieldSet>
</FieldGroup>
<DialogFooter className="m-0 min-w-0 p-6">
<Button onClick={handleCopyTheme} className="h-9 w-full">
{copiedTarget === "theme" ? "Copied" : "Copy Theme"}
</Button>
</DialogFooter>
</div>
)}
</div>
</DialogContent>
</Dialog>
)
@@ -322,6 +644,34 @@ const TemplateGrid = React.memo(function TemplateGrid({
)
})
const ApplyModeGrid = React.memo(function ApplyModeGrid({
mode,
setMode,
}: {
mode: ApplyMode
setMode: (mode: ApplyMode) => void
}) {
return (
<RadioGroup
value={mode}
onValueChange={(value) => setMode(value as ApplyMode)}
aria-label="Apply"
>
{APPLY_MODES.map((option) => (
<FieldLabel key={option.value} htmlFor={`apply-${option.value}`}>
<Field orientation="horizontal">
<RadioGroupItem value={option.value} id={`apply-${option.value}`} />
<FieldContent>
<FieldTitle>{option.title}</FieldTitle>
<FieldDescription>{option.description}</FieldDescription>
</FieldContent>
</Field>
</FieldLabel>
))}
</RadioGroup>
)
})
const BaseGrid = React.memo(function BaseGrid({
base,
setParams,

View File

@@ -23,7 +23,7 @@ export function RadiusPicker({
anchorRef: React.RefObject<HTMLDivElement | null>
}) {
const [params, setParams] = useDesignSystemSearchParams()
const isRadiusLocked = params.style === "lyra"
const isRadiusLocked = params.style === "lyra" || params.style === "sera"
const selectedRadiusName = isRadiusLocked ? "none" : params.radius
const currentRadius = RADII.find(

View File

@@ -28,7 +28,7 @@ export function RandomButton({
)}
{...props}
>
<span className="w-full text-center font-medium">Shuffle</span>
<span className="w-full truncate text-center font-medium">Shuffle</span>
</Button>
)
}

View File

@@ -16,8 +16,17 @@ export function ShareButton() {
const shareUrl = React.useMemo(() => {
const origin = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
return `${origin}/create?preset=${presetCode}&item=${params.item}`
}, [presetCode, params.item])
const searchParams = new URLSearchParams({
preset: presetCode,
item: params.item,
})
if (params.pointer) {
searchParams.set("pointer", "true")
}
return `${origin}/create?${searchParams.toString()}`
}, [params.item, params.pointer, presetCode])
React.useEffect(() => {
if (hasCopied) {

View File

@@ -0,0 +1,81 @@
"use client"
import * as React from "react"
import useSWR from "swr"
const OPEN_PRESET_KEY = "create:open-preset-open"
export const OPEN_PRESET_FORWARD_TYPE = "open-preset-forward"
function isEditableTarget(target: EventTarget | null) {
return (
(target instanceof HTMLElement && target.isContentEditable) ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
)
}
export function useOpenPreset() {
const { data: open = false, mutate: setOpenData } = useSWR<boolean>(
OPEN_PRESET_KEY,
{
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
)
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
void setOpenData(nextOpen, { revalidate: false })
},
[setOpenData]
)
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (
e.key === "o" &&
!e.shiftKey &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey
) {
if (isEditableTarget(e.target)) {
return
}
e.preventDefault()
void setOpenData(true, { revalidate: false })
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [setOpenData])
return {
open,
setOpen: handleOpenChange,
}
}
export function useOpenPresetTrigger() {
const { mutate: setOpenData } = useSWR<boolean>(OPEN_PRESET_KEY, {
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
})
const openPreset = React.useCallback(() => {
void setOpenData(true, { revalidate: false })
}, [setOpenData])
return {
openPreset,
}
}

View File

@@ -1,10 +1,12 @@
import {
DM_Sans,
EB_Garamond,
Figtree,
Geist,
Geist_Mono,
IBM_Plex_Sans,
Instrument_Sans,
Instrument_Serif,
Inter,
JetBrains_Mono,
Lora,
@@ -149,6 +151,17 @@ const playfairDisplay = Playfair_Display({
variable: "--font-playfair-display",
})
const ebGaramond = EB_Garamond({
subsets: ["latin"],
variable: "--font-eb-garamond",
})
const instrumentSerif = Instrument_Serif({
subsets: ["latin"],
weight: "400",
variable: "--font-instrument-serif",
})
const PREVIEW_FONTS = {
geist: geistSans,
inter,
@@ -174,6 +187,8 @@ const PREVIEW_FONTS = {
merriweather,
lora,
"playfair-display": playfairDisplay,
"eb-garamond": ebGaramond,
"instrument-serif": instrumentSerif,
} satisfies Record<FontName, PreviewFont>
function createFontOption(name: FontName) {
@@ -216,6 +231,8 @@ export const FONTS = [
createFontOption("merriweather"),
createFontOption("lora"),
createFontOption("playfair-display"),
createFontOption("eb-garamond"),
createFontOption("instrument-serif"),
] as const
export type Font = (typeof FONTS)[number]

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest"
import { parsePresetInput } from "./parse-preset-input"
describe("parsePresetInput", () => {
it("accepts a raw preset code", () => {
expect(parsePresetInput("b0")).toBe("b0")
})
it("accepts a --preset flag", () => {
expect(parsePresetInput(" --preset b0 ")).toBe("b0")
})
it("rejects invalid preset input", () => {
expect(parsePresetInput("open sesame")).toBeNull()
})
})

View File

@@ -0,0 +1,15 @@
import { isPresetCode } from "shadcn/preset"
const PRESET_FLAG_PATTERN = /^--preset\b\s+(.+)$/i
export function parsePresetInput(value: string) {
const input = value.trim()
if (!input) {
return null
}
const preset = input.match(PRESET_FLAG_PATTERN)?.[1]?.trim() ?? input
return isPresetCode(preset) ? preset : null
}

View File

@@ -91,6 +91,7 @@ const designSystemSearchParams = {
"laravel",
] as const).withDefault("next"),
rtl: parseAsBoolean.withDefault(false),
pointer: parseAsBoolean.withDefault(false),
size: parseAsInteger.withDefault(100),
custom: parseAsBoolean.withDefault(false),
}
@@ -126,6 +127,7 @@ const NON_DESIGN_SYSTEM_KEYS = [
"preset",
"template",
"rtl",
"pointer",
"size",
"custom",
] as const
@@ -224,6 +226,7 @@ function resolvePresetParams(
preset: rawParams.preset,
template: rawParams.template,
rtl: rawParams.rtl,
pointer: rawParams.pointer,
size: rawParams.size,
custom: rawParams.custom,
})

View File

@@ -4,6 +4,7 @@ import { mdxComponents } from "@/mdx-components"
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react"
import { findNeighbour } from "fumadocs-core/page-tree"
import { replaceComponentsList } from "@/lib/llm"
import { source } from "@/lib/source"
import { absoluteUrl } from "@/lib/utils"
import { DocsBaseSwitcher } from "@/components/docs-base-switcher"
@@ -83,7 +84,7 @@ export default async function Page(props: {
const neighbours = isChangelog
? { previous: null, next: null }
: findNeighbour(source.pageTree, page.url)
const raw = await page.data.getText("raw")
const raw = replaceComponentsList(await page.data.getText("raw"))
return (
<div

View File

@@ -10,7 +10,7 @@ import { Button } from "@/styles/radix-nova/ui/button"
export const revalidate = false
export const dynamic = "force-static"
const NUMBER_OF_LATEST_PAGES = 2
const NUMBER_OF_LATEST_PAGES = 5
export function generateMetadata() {
return {

View File

@@ -6,6 +6,8 @@ import { source } from "@/lib/source"
import { getActiveStyle, type Style } from "@/registry/_legacy-styles"
export const revalidate = false
export const dynamic = "force-static"
export const dynamicParams = true
function getStyleFromSlug(slug: string[] | undefined, fallbackStyle: string) {
// Detect base from URL: /docs/components/base/... or /docs/components/radix/...
@@ -47,5 +49,5 @@ export async function GET(
}
export function generateStaticParams() {
return source.generateParams()
return []
}

View File

@@ -3,6 +3,51 @@ import { describe, expect, it } from "vitest"
import { parseDesignSystemConfig } from "./parse-config"
describe("parseDesignSystemConfig", () => {
it("defaults pointer to false when omitted", () => {
const result = parseDesignSystemConfig(
new URLSearchParams(
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
)
)
expect(result.success).toBe(true)
if (!result.success) {
throw new Error(result.error)
}
expect(result.data.pointer).toBe(false)
})
it("parses pointer=true", () => {
const result = parseDesignSystemConfig(
new URLSearchParams(
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&pointer=true&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
)
)
expect(result.success).toBe(true)
if (!result.success) {
throw new Error(result.error)
}
expect(result.data.pointer).toBe(true)
})
it("defaults missing chartColor from the selected theme", () => {
const result = parseDesignSystemConfig(
new URLSearchParams(
"base=base&style=sera&baseColor=taupe&theme=taupe&iconLibrary=lucide&font=noto-sans&rtl=false&menuAccent=subtle&menuColor=default&radius=default&fontHeading=playfair-display&template=vite&track=1"
)
)
expect(result.success).toBe(true)
if (!result.success) {
throw new Error(result.error)
}
expect(result.data.chartColor).toBe("taupe")
})
it("honors explicit fontHeading and chartColor overrides when a preset is present", () => {
const result = parseDesignSystemConfig(
new URLSearchParams(
@@ -18,4 +63,17 @@ describe("parseDesignSystemConfig", () => {
expect(result.data.fontHeading).toBe("playfair-display")
expect(result.data.chartColor).toBe("emerald")
})
it("keeps pointer outside preset decoding", () => {
const result = parseDesignSystemConfig(
new URLSearchParams("preset=a0&pointer=true")
)
expect(result.success).toBe(true)
if (!result.success) {
throw new Error(result.error)
}
expect(result.data.pointer).toBe(true)
})
})

View File

@@ -23,6 +23,7 @@ export function parseDesignSystemConfig(searchParams: URLSearchParams) {
base: searchParams.get("base") ?? "radix",
template: searchParams.get("template") ?? undefined,
rtl: searchParams.get("rtl") === "true",
pointer: searchParams.get("pointer") === "true",
}
} else {
configInput = {
@@ -39,6 +40,7 @@ export function parseDesignSystemConfig(searchParams: URLSearchParams) {
radius: searchParams.get("radius"),
template: searchParams.get("template") ?? undefined,
rtl: searchParams.get("rtl") === "true",
pointer: searchParams.get("pointer") === "true",
}
}

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest"
import {
buildRegistryBase,
DEFAULT_CONFIG,
POINTER_CURSOR_SELECTOR,
} from "@/registry/config"
import { GET } from "./route"
function createRequest(search = "") {
const searchParams = new URLSearchParams(
Object.entries(DEFAULT_CONFIG).map(([key, value]) => [key, String(value)])
)
const url = new URL(`http://localhost:4000/init${search}`)
for (const [key, value] of url.searchParams) {
searchParams.set(key, value)
}
return {
nextUrl: new URL(`http://localhost:4000/init?${searchParams}`),
} as Parameters<typeof GET>[0]
}
describe("GET /init", () => {
it("returns the full registry base when only is omitted", async () => {
const response = await GET(createRequest())
const json = await response.json()
expect(response.status).toBe(200)
expect(json).toEqual(buildRegistryBase(DEFAULT_CONFIG))
expect(json.css["@layer base"][POINTER_CURSOR_SELECTOR]).toBeUndefined()
})
it("returns pointer cursor css when pointer is enabled", async () => {
const response = await GET(createRequest("?pointer=true"))
const json = await response.json()
expect(response.status).toBe(200)
expect(json.css["@layer base"][POINTER_CURSOR_SELECTOR]).toEqual({
cursor: "pointer",
})
})
it("returns a sparse registry base when only is provided", async () => {
const response = await GET(createRequest("?only=theme"))
const json = await response.json()
expect(response.status).toBe(200)
expect(json.type).toBe("registry:base")
expect(json.config).toEqual({
menuColor: "default",
menuAccent: "subtle",
tailwind: {
baseColor: "neutral",
},
})
expect(json.cssVars.light).toBeDefined()
expect(json.cssVars.light.radius).toBe("0.625rem")
expect(json.dependencies).toBeUndefined()
expect(json.registryDependencies).toBeUndefined()
})
it("rejects unsupported only values", async () => {
const response = await GET(createRequest("?only=icon"))
const json = await response.json()
expect(response.status).toBe(400)
expect(json.error).toBe(
"Invalid only value. Use one or more of: theme, font"
)
})
})

View File

@@ -3,7 +3,11 @@ import { track } from "@vercel/analytics/server"
import { isPresetCode } from "shadcn/preset"
import { registryItemSchema } from "shadcn/schema"
import { buildRegistryBase } from "@/registry/config"
import {
buildPartialRegistryBase,
buildRegistryBase,
parseRegistryBaseParts,
} from "@/registry/config"
import { getPresetCode } from "@/app/(app)/create/lib/preset-code"
import { parseDesignSystemConfig } from "@/app/(create)/init/parse-config"
@@ -16,13 +20,20 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
const onlyResult = parseRegistryBaseParts(searchParams.get("only"))
if (!onlyResult.success) {
return NextResponse.json({ error: onlyResult.error }, { status: 400 })
}
const rawPreset = searchParams.get("preset")
const presetCode =
rawPreset && isPresetCode(rawPreset)
? rawPreset
: getPresetCode(result.data)
const registryBase = buildRegistryBase(result.data)
const registryBase = onlyResult.parts
? buildPartialRegistryBase(result.data, onlyResult.parts)
: buildRegistryBase(result.data)
const parseResult = registryItemSchema.safeParse(registryBase)
if (!parseResult.success) {

View File

@@ -5,22 +5,21 @@ import { notFound } from "next/navigation"
import { siteConfig } from "@/lib/config"
import { absoluteUrl } from "@/lib/utils"
import { TailwindIndicator } from "@/components/tailwind-indicator"
import { BASES, type Base, type BaseName } from "@/registry/config"
import { BASES, type Base } from "@/registry/config"
import { ActionMenuScript } from "@/app/(app)/create/components/action-menu"
import { DesignSystemProvider } from "@/app/(app)/create/components/design-system-provider"
import { HistoryScript } from "@/app/(app)/create/components/history-buttons"
import { DarkModeScript } from "@/app/(app)/create/components/mode-switcher"
import { OpenPresetScript } from "@/app/(app)/create/components/open-preset"
import { PreviewStyle } from "@/app/(app)/create/components/preview-style"
import { RandomizeScript } from "@/app/(app)/create/components/random-button"
import {
getBaseComponent,
getBaseItem,
getItemsForBase,
} from "@/app/(app)/create/lib/api"
import { getBaseComponent, getBaseItem } from "@/app/(app)/create/lib/api"
export const revalidate = false
export const dynamic = "force-static"
export const dynamicParams = false
export const dynamicParams = true
const STATIC_PREVIEW_ITEMS = ["preview", "preview-02"] as const
function PreventScrollOnFocusScript() {
return (
@@ -95,19 +94,12 @@ export async function generateMetadata({
}
export async function generateStaticParams() {
const params: Array<{ base: string; name: string }> = []
for (const base of BASES) {
const items = await getItemsForBase(base.name as BaseName)
for (const item of items) {
params.push({
base: base.name,
name: item.name,
})
}
}
return params
return BASES.flatMap((base) =>
STATIC_PREVIEW_ITEMS.map((name) => ({
base: base.name,
name,
}))
)
}
export default async function BlockPage({
@@ -139,6 +131,7 @@ export default async function BlockPage({
<PreventScrollOnFocusScript />
<PreviewStyle />
<ActionMenuScript />
<OpenPresetScript />
<RandomizeScript />
<HistoryScript />
<DarkModeScript />

View File

@@ -9,6 +9,7 @@
@import "../registry/styles/style-maia.css" layer(base);
@import "../registry/styles/style-mira.css" layer(base);
@import "../registry/styles/style-luma.css" layer(base);
@import "../registry/styles/style-sera.css" layer(base);
@custom-variant style-vega (&:where(.style-vega *));
@custom-variant style-nova (&:where(.style-nova *));
@@ -16,6 +17,7 @@
@custom-variant style-maia (&:where(.style-maia *));
@custom-variant style-mira (&:where(.style-mira *));
@custom-variant style-luma (&:where(.style-luma *));
@custom-variant style-sera (&:where(.style-sera *));
@custom-variant dark (&:is(.dark *));
@custom-variant fixed (&:is(.layout-fixed *));

View File

@@ -7,7 +7,7 @@ export function Announcement() {
return (
<Badge asChild variant="secondary" className="bg-muted">
<Link href="/docs/changelog">
Introducing Luma <ArrowRightIcon />
New preset commands <ArrowRightIcon />
</Link>
</Badge>
)

View File

@@ -6,7 +6,7 @@ import { IconCheck, IconCopy, IconPlus } from "@tabler/icons-react"
import { useConfig } from "@/hooks/use-config"
import { useIsMobile } from "@/hooks/use-mobile"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/registry/new-york-v4/ui/button"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogClose,
@@ -15,8 +15,7 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/registry/new-york-v4/ui/dialog"
} from "@/styles/base-nova/ui/dialog"
import {
Drawer,
DrawerClose,
@@ -25,42 +24,74 @@ import {
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/registry/new-york-v4/ui/drawer"
} from "@/styles/base-nova/ui/drawer"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/registry/new-york-v4/ui/tabs"
} from "@/styles/base-nova/ui/tabs"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/new-york-v4/ui/tooltip"
} from "@/styles/base-nova/ui/tooltip"
const DirectoryAddContext = React.createContext<{
open: (registry: { name: string }) => void
}>({
open: () => {},
})
export function useDirectoryAdd() {
return React.useContext(DirectoryAddContext)
}
export function DirectoryAddButton({
registry,
}: {
registry: { name: string }
}) {
const { open } = useDirectoryAdd()
return (
<Button
size="sm"
variant="outline"
className="relative z-10"
onClick={() => open(registry)}
>
Add <IconPlus />
</Button>
)
}
export function DirectoryAddProvider({
children,
}: {
children: React.ReactNode
}) {
const [config, setConfig] = useConfig()
const [hasCopied, setHasCopied] = React.useState(false)
const [open, setOpen] = React.useState(false)
const [isOpen, setIsOpen] = React.useState(false)
const [selectedRegistry, setSelectedRegistry] = React.useState<{
name: string
} | null>(null)
const isMobile = useIsMobile()
const packageManager = config.packageManager || "pnpm"
const commands = React.useMemo(() => {
if (!selectedRegistry) return null
return {
pnpm: `pnpm dlx shadcn@latest registry add ${registry.name}`,
npm: `npx shadcn@latest registry add ${registry.name}`,
yarn: `yarn dlx shadcn@latest registry add ${registry.name}`,
bun: `bunx --bun shadcn@latest registry add ${registry.name}`,
pnpm: `pnpm dlx shadcn@latest registry add ${selectedRegistry.name}`,
npm: `npx shadcn@latest registry add ${selectedRegistry.name}`,
yarn: `yarn dlx shadcn@latest registry add ${selectedRegistry.name}`,
bun: `bunx --bun shadcn@latest registry add ${selectedRegistry.name}`,
}
}, [registry.name])
}, [selectedRegistry])
const command = commands[packageManager]
const command = commands?.[packageManager] ?? ""
React.useEffect(() => {
if (hasCopied) {
@@ -74,19 +105,23 @@ export function DirectoryAddButton({
name: "copy_registry_add_command",
properties: {
command,
registry: registry.name,
registry: selectedRegistry?.name ?? "",
},
})
setHasCopied(true)
}, [command, registry.name])
}, [command, selectedRegistry?.name])
const Trigger = (
<Button size="sm" variant="outline" className="relative z-10">
Add <IconPlus />
</Button>
const contextValue = React.useMemo(
() => ({
open: (registry: { name: string }) => {
setSelectedRegistry(registry)
setIsOpen(true)
},
}),
[]
)
const Content = (
const Content = commands ? (
<Tabs
value={packageManager}
onValueChange={(value) => {
@@ -95,30 +130,28 @@ export function DirectoryAddButton({
packageManager: value as "pnpm" | "npm" | "yarn" | "bun",
})
}}
className="gap-0 overflow-hidden rounded-lg border"
className="gap-0 overflow-hidden rounded-xl border"
>
<div className="flex items-center gap-2 border-b p-2">
<TabsList className="h-auto rounded-none bg-transparent p-0 font-mono *:data-[slot=tabs-trigger]:border *:data-[slot=tabs-trigger]:border-transparent *:data-[slot=tabs-trigger]:pt-0.5 *:data-[slot=tabs-trigger]:shadow-none! *:data-[slot=tabs-trigger]:data-[state=active]:border-input">
<div className="flex items-center gap-2 border-b p-1.5">
<TabsList className="h-auto *:data-[slot=tabs-trigger]:pt-0">
<TabsTrigger value="pnpm">pnpm</TabsTrigger>
<TabsTrigger value="npm">npm</TabsTrigger>
<TabsTrigger value="yarn">yarn</TabsTrigger>
<TabsTrigger value="bun">bun</TabsTrigger>
</TabsList>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto size-7 rounded-lg"
onClick={handleCopy}
>
{hasCopied ? (
<IconCheck className="size-4" />
) : (
<IconCopy className="size-4" />
)}
<span className="sr-only">Copy command</span>
</Button>
<TooltipTrigger
render={
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopy}
/>
}
>
{hasCopied ? <IconCheck /> : <IconCopy />}
<span className="sr-only">Copy command</span>
</TooltipTrigger>
<TooltipContent>
{hasCopied ? "Copied!" : "Copy command"}
@@ -135,47 +168,48 @@ export function DirectoryAddButton({
</TabsContent>
))}
</Tabs>
)
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{Trigger}</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Add Registry</DrawerTitle>
<DrawerDescription>
Run this command to add {registry.name} to your project.
</DrawerDescription>
</DrawerHeader>
<div className="px-4">{Content}</div>
<DrawerFooter>
<DrawerClose asChild>
<Button size="sm">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
) : null
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{Trigger}</DialogTrigger>
<DialogContent className="dialog-ring animate-none! rounded-xl sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Registry</DialogTitle>
<DialogDescription>
Run this command to add {registry.name} to your project.
</DialogDescription>
</DialogHeader>
{Content}
<DialogFooter>
<DialogClose asChild>
<Button size="sm">Done</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<DirectoryAddContext value={contextValue}>
{children}
{isMobile ? (
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Add Registry</DrawerTitle>
<DrawerDescription>
Run this command to add {selectedRegistry?.name} to your
project.
</DrawerDescription>
</DrawerHeader>
<div className="px-4">{Content}</div>
<DrawerFooter>
<DrawerClose asChild>
<Button size="sm">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
) : (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="animate-none! sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Registry</DialogTitle>
<DialogDescription>
Run this command to add {selectedRegistry?.name} to your
project.
</DialogDescription>
</DialogHeader>
{Content}
<DialogFooter>
<DialogClose render={<Button size="sm" variant="outline" />}>
Done
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DirectoryAddContext>
)
}

View File

@@ -1,12 +1,20 @@
"use client"
import * as React from "react"
import { IconArrowUpRight } from "@tabler/icons-react"
import { usePathname } from "next/navigation"
import {
IconArrowUpRight,
IconChevronLeft,
IconChevronRight,
} from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import { useSearchRegistry } from "@/hooks/use-search-registry"
import { DirectoryAddButton } from "@/components/directory-add-button"
import globalRegistries from "@/registry/directory.json"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
DirectoryAddButton,
DirectoryAddProvider,
} from "@/components/directory-add-button"
import { Button, buttonVariants } from "@/styles/base-nova/ui/button"
import {
Item,
ItemActions,
@@ -17,9 +25,16 @@ import {
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/registry/new-york-v4/ui/item"
} from "@/styles/base-nova/ui/item"
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
} from "@/styles/base-nova/ui/pagination"
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
import { SearchDirectory } from "./search-directory"
import { SearchDirectory } from "./directory-search"
function getHomepageUrl(homepage: string) {
const url = new URL(homepage)
@@ -29,55 +44,308 @@ function getHomepageUrl(homepage: string) {
return url.toString()
}
function getPageHref(pathname: string, query: string, page: number) {
const searchParams = new URLSearchParams()
if (query) {
searchParams.set("q", query)
}
if (page > 1) {
searchParams.set("page", page.toString())
}
const search = searchParams.toString()
return search ? `${pathname}?${search}` : pathname
}
function getPageNumbers(current: number, total: number) {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1) as (
| number
| "ellipsis"
)[]
}
const pages: (number | "ellipsis")[] = [1]
// Show ellipsis or page 2 directly if only one number would be hidden.
if (current > 4) {
pages.push("ellipsis")
} else if (current >= 4) {
pages.push(2)
}
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
// Show ellipsis or second-to-last page directly if only one number would be hidden.
if (current < total - 3) {
pages.push("ellipsis")
} else if (current <= total - 3) {
pages.push(total - 1)
}
pages.push(total)
return pages
}
type DirectoryPaginationLinkProps = React.ComponentProps<"a"> & {
isActive?: boolean
size?: React.ComponentProps<typeof Button>["size"]
}
function DirectoryPaginationLink({
className,
isActive,
size = "icon",
...props
}: DirectoryPaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function DirectoryPaginationPrevious({
className,
text = "Previous",
...props
}: DirectoryPaginationLinkProps & { text?: string }) {
return (
<DirectoryPaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<IconChevronLeft className="cn-rtl-flip size-4" />
<span className="hidden sm:block">{text}</span>
</DirectoryPaginationLink>
)
}
function DirectoryPaginationNext({
className,
text = "Next",
...props
}: DirectoryPaginationLinkProps & { text?: string }) {
return (
<DirectoryPaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<IconChevronRight className="cn-rtl-flip size-4" />
</DirectoryPaginationLink>
)
}
export function DirectoryList() {
const { registries } = useSearchRegistry()
const pathname = usePathname()
const {
isLoading,
paginatedRegistries,
page,
query,
registries,
totalPages,
setPage,
setQuery,
} = useSearchRegistry()
const previousHref =
page > 1 ? getPageHref(pathname, query, page - 1) : undefined
const nextHref =
page < totalPages ? getPageHref(pathname, query, page + 1) : undefined
const handlePageChange = React.useCallback(
(
event: React.MouseEvent<HTMLAnchorElement>,
targetPage: number,
disabled = false
) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
if (disabled || targetPage === page) {
event.preventDefault()
return
}
event.preventDefault()
void setPage(targetPage)
},
[page, setPage]
)
return (
<div className="mt-6">
<SearchDirectory />
<DirectoryAddProvider>
<div className="mt-6">
{isLoading ? (
<DirectoryListSkeleton />
) : (
<>
<SearchDirectory
query={query}
registriesCount={registries.length}
setQuery={setQuery}
/>
<ItemGroup className="my-8">
{paginatedRegistries.map((registry, index) => (
<React.Fragment key={registry.name}>
<Item className="group/item relative gap-6 px-0">
<ItemMedia
variant="image"
dangerouslySetInnerHTML={{ __html: registry.logo }}
className="grayscale *:[svg]:size-8 *:[svg]:fill-foreground"
/>
<ItemContent>
<ItemTitle>
<a
href={getHomepageUrl(registry.homepage)}
target="_blank"
rel="noopener noreferrer external"
className="group flex items-center gap-1"
>
{registry.name}{" "}
<IconArrowUpRight className="size-4 opacity-0 group-hover:opacity-100" />
</a>
</ItemTitle>
{registry.description && (
<ItemDescription className="text-pretty">
{registry.description}
</ItemDescription>
)}
</ItemContent>
<ItemActions className="relative z-10 hidden self-start sm:flex">
<DirectoryAddButton registry={registry} />
</ItemActions>
<ItemFooter className="justify-start pl-16 sm:hidden">
<Button size="sm" variant="outline">
View <IconArrowUpRight />
</Button>
<DirectoryAddButton registry={registry} />
</ItemFooter>
</Item>
{index < paginatedRegistries.length - 1 && (
<ItemSeparator className="my-1" />
)}
</React.Fragment>
))}
</ItemGroup>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<DirectoryPaginationPrevious
href={previousHref}
aria-disabled={page <= 1 || undefined}
tabIndex={page <= 1 ? -1 : undefined}
onClick={(event) =>
handlePageChange(event, page - 1, page <= 1)
}
className={cn(
page <= 1
? "pointer-events-none opacity-50"
: "cursor-pointer"
)}
/>
</PaginationItem>
{getPageNumbers(page, totalPages).map((p, i) =>
p === "ellipsis" ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<DirectoryPaginationLink
href={getPageHref(pathname, query, p)}
isActive={p === page}
onClick={(event) => handlePageChange(event, p)}
className="cursor-pointer"
>
{p}
</DirectoryPaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<DirectoryPaginationNext
href={nextHref}
aria-disabled={page >= totalPages || undefined}
tabIndex={page >= totalPages ? -1 : undefined}
onClick={(event) =>
handlePageChange(event, page + 1, page >= totalPages)
}
className={cn(
page >= totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"
)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</>
)}
</div>
</DirectoryAddProvider>
)
}
function DirectoryListSkeleton() {
return (
<>
<Skeleton className="h-8 w-full rounded-lg" />
<ItemGroup className="my-8">
{registries.map((registry, index) => (
{Array.from({ length: 10 }, (_, index) => (
<React.Fragment key={index}>
<Item className="group/item relative gap-6 px-0">
<ItemMedia
variant="image"
dangerouslySetInnerHTML={{ __html: registry.logo }}
className="grayscale *:[svg]:size-8 *:[svg]:fill-foreground"
/>
<Item className="relative items-start gap-6 px-0">
<Skeleton className="size-8 rounded-lg" />
<ItemContent>
<ItemTitle>
<a
href={getHomepageUrl(registry.homepage)}
target="_blank"
rel="noopener noreferrer external"
className="group flex items-center gap-1"
>
{registry.name}{" "}
<IconArrowUpRight className="size-4 opacity-0 group-hover:opacity-100" />
</a>
</ItemTitle>
{registry.description && (
<ItemDescription className="text-pretty">
{registry.description}
</ItemDescription>
)}
<Skeleton className="h-4 w-32 sm:w-40" />
<Skeleton className="mt-1.5 h-4 w-full max-w-md" />
<Skeleton className="mt-1 h-4 w-3/4 max-w-sm" />
</ItemContent>
<ItemActions className="relative z-10 hidden self-start sm:flex">
<DirectoryAddButton registry={registry} />
<ItemActions className="hidden self-start sm:flex">
<Skeleton className="h-7 w-16 rounded-lg" />
</ItemActions>
<ItemFooter className="justify-start pl-16 sm:hidden">
<Button size="sm" variant="outline">
View <IconArrowUpRight />
</Button>
<DirectoryAddButton registry={registry} />
<ItemFooter className="justify-start gap-2 pl-16 sm:hidden">
<Skeleton className="h-9 w-20 rounded-lg" />
<Skeleton className="h-9 w-24 rounded-lg" />
</ItemFooter>
</Item>
{index < globalRegistries.length - 1 && (
<ItemSeparator className="my-1" />
)}
{index < 9 && <ItemSeparator className="my-1" />}
</React.Fragment>
))}
</ItemGroup>
</div>
</>
)
}

View File

@@ -1,23 +1,24 @@
import * as React from "react"
"use client"
import { Search, X } from "lucide-react"
import { useSearchRegistry } from "@/hooks/use-search-registry"
import { Field } from "@/registry/new-york-v4/ui/field"
import { Field } from "@/styles/base-nova/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/registry/new-york-v4/ui/input-group"
export const SearchDirectory = () => {
const { query, registries, setQuery } = useSearchRegistry()
const onQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setQuery(value)
}
} from "@/styles/base-nova/ui/input-group"
export function SearchDirectory({
query,
registriesCount,
setQuery,
}: {
query: string
registriesCount: number
setQuery: (value: string | null) => void
}) {
return (
<Field>
<InputGroup>
@@ -25,14 +26,15 @@ export const SearchDirectory = () => {
<Search />
</InputGroupAddon>
<InputGroupInput
className="h-full"
placeholder="Search"
value={query}
onChange={onQueryChange}
onChange={(e) => setQuery(e.target.value)}
/>
<InputGroupAddon align="inline-end">
<span className="text-muted-foreground tabular-nums sm:text-xs">
{registries.length}{" "}
{registries.length === 1 ? "registry" : "registries"}
{registriesCount}{" "}
{registriesCount === 1 ? "registry" : "registries"}
</span>
</InputGroupAddon>
<InputGroupAddon

View File

@@ -1,4 +1,4 @@
const SHOW = false
const SHOW = true
export function TailwindIndicator({
forceMount = false,

View File

@@ -39,6 +39,8 @@ Options:
--no-monorepo skip the monorepo prompt.
--rtl enable RTL support.
--no-rtl disable RTL support.
--pointer enable pointer cursor for buttons.
--no-pointer disable pointer cursor for buttons.
--reinstall re-install existing UI components.
--no-reinstall do not re-install existing UI components.
-h, --help display help for command
@@ -90,9 +92,17 @@ Options:
Use the `apply` command to apply a preset to an existing project.
```bash
npx shadcn@latest apply --preset a2r6bw
npx shadcn@latest apply a2r6bw
```
You can apply only the theme or fonts from a preset without reinstalling UI components:
```bash
npx shadcn@latest apply a2r6bw --only theme
```
Supported values for `--only` are `theme` and `font`.
**Options**
```bash
@@ -105,6 +115,7 @@ Arguments:
Options:
--preset <preset> preset configuration to apply
--only [parts] apply only parts of a preset: theme, font
-y, --yes skip confirmation prompt. (default: false)
-c, --cwd <cwd> the working directory. defaults to the current directory.
-s, --silent mute output. (default: false)
@@ -113,6 +124,110 @@ Options:
---
## preset
Use the `preset` command to inspect preset codes and resolve the preset for an existing project.
```bash
npx shadcn@latest preset decode a2r6bw
```
### preset decode
Use `preset decode` to decode a preset code.
```bash
npx shadcn@latest preset decode a2r6bw
```
**Options**
```bash
Usage: shadcn preset decode [options] <code>
decode a preset code
Arguments:
code the preset code to decode
Options:
--json output as JSON. (default: false)
-h, --help display help for command
```
### preset resolve
Use `preset resolve` to resolve the preset from the current project.
```bash
npx shadcn@latest preset resolve
```
The `preset info` command is an alias for `preset resolve`:
```bash
npx shadcn@latest preset info
```
**Options**
```bash
Usage: shadcn preset resolve|info [options]
resolve a preset from your project
Options:
-c, --cwd <cwd> the working directory. defaults to the current directory.
--json output as JSON. (default: false)
-h, --help display help for command
```
### preset url
Use `preset url` to print the create URL for a preset code.
```bash
npx shadcn@latest preset url a2r6bw
```
**Options**
```bash
Usage: shadcn preset url [options] <code>
get the create URL for a preset code
Arguments:
code the preset code
Options:
-h, --help display help for command
```
### preset open
Use `preset open` to open a preset code in the browser.
```bash
npx shadcn@latest preset open a2r6bw
```
**Options**
```bash
Usage: shadcn preset open [options] <code>
open a preset code in the browser
Arguments:
code the preset code
Options:
-h, --help display help for command
```
---
## view
Use the `view` command to view items from the registry before installing them.

View File

@@ -140,15 +140,91 @@ Setting this option to `false` allows components to be added as JavaScript with
## aliases
The CLI uses these values and the `paths` config from your `tsconfig.json` or `jsconfig.json` file to place generated components in the correct location.
The CLI uses these values to place generated components in the correct location and rewrite imports.
Path aliases have to be set up in your `tsconfig.json` or `jsconfig.json` file.
You can back these aliases with either:
1. `compilerOptions.paths` in your `tsconfig.json` or `jsconfig.json`
2. `package.json#imports` with TypeScript package import resolution enabled
The aliases in `components.json` are still required when using the CLI. They tell the CLI which import roots map to `components`, `ui`, `lib`, `hooks`, and `utils`.
<Callout className="mt-6">
**Important:** If you're using the `src` directory, make sure it is included
under `paths` in your `tsconfig.json` or `jsconfig.json` file.
**Important:** If you're using package imports, enable
`resolvePackageJsonImports` and use `moduleResolution: "bundler"` in your
`tsconfig.json`. If you're using `paths`, make sure your aliases include the
`src` directory when applicable.
</Callout>
### Using `tsconfig` or `jsconfig` paths
```json title="tsconfig.json"
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
```
### Using `package.json#imports`
Recommended setup for a single-package app:
```json title="package.json"
{
"imports": {
"#components/*": "./src/components/*.tsx",
"#lib/*": "./src/lib/*.ts",
"#hooks/*": "./src/hooks/*.ts"
}
}
```
```json title="tsconfig.json"
{
"compilerOptions": {
"moduleResolution": "bundler",
"resolvePackageJsonImports": true
}
}
```
```json title="components.json"
{
"aliases": {
"components": "#components",
"ui": "#components/ui",
"lib": "#lib",
"hooks": "#hooks",
"utils": "#lib/utils"
}
}
```
The aliases in `components.json` still tell the CLI where to place
`components`, `ui`, `lib`, `hooks`, and `utils`. `package.json#imports`
provides the runtime and TypeScript resolution for those `#...` specifiers.
The matched `imports` target also controls whether generated `#...` imports keep
file extensions:
- `"#components/*": "./src/components/*"` preserves source extensions and can
generate imports like
`#components/button.tsx`
- `"#components/*": "./src/components/*.tsx"` strips source extensions and
generates imports like
`#components/button`
For monorepos, see the <Link href="/docs/monorepo">monorepo docs</Link>. Local
workspace aliases can use `package.json#imports`, while shared workspace
imports such as `@workspace/ui/components` are resolved from the target
package's `exports`.
For framework-specific setup, see the [package imports guide](/docs/package-imports).
### aliases.utils
Import alias for your utility functions.

View File

@@ -3,14 +3,9 @@ title: Registry Directory
description: Discover community registries for shadcn/ui components and blocks.
---
import { TriangleAlertIcon } from "lucide-react"
These registries are built into the CLI with no additional configuration required. To add a component, run: `npx shadcn add @<registry>/<component>`.
<Callout
type="warning"
className="border-amber-200 bg-amber-50 font-semibold dark:border-amber-900 dark:bg-amber-950"
>
<Callout className="bg-muted font-semibold">
Community registries are maintained by third-party developers. Always review
code on installation to ensure it meets your security and quality standards.
</Callout>

View File

@@ -10,7 +10,6 @@ description: Every component recreated in Figma. With customizable props, typogr
## Free
- [Obra shadcn/ui](https://www.figma.com/community/file/1514746685758799870/obra-shadcn-ui) by [Obra Studio](https://obra.studio/) - Carefully crafted shadcn/ui kit, MIT licensed, maintained by team of designers, with free design to code plugin
- [shadcn/ui components](https://www.figma.com/community/file/1342715840824755935) by [Sitsiilia Bergmann](https://x.com/sitsiilia) - A well-structured component library aligned with the shadcn component system, regularly maintained.
- [shadcn/ui design system](https://www.figma.com/community/file/1203061493325953101) by [Pietro Schirano](https://twitter.com/skirano) - A design companion for shadcn/ui. Each component was painstakingly crafted to perfectly match the code implementation.
@@ -20,3 +19,4 @@ description: Every component recreated in Figma. With customizable props, typogr
- [shadcncraft Design System](https://shadcncraft.com) - Production-ready shadcn/ui kit with Pro React blocks and 1:1 Figma alignment. Design and ship faster with tweakcn theming, AI-assisted workflows, and Figma to React export, built for real product UIs.
- [shadcn/studio UI Kit](https://shadcnstudio.com/figma) - Accelerate design & development with a shadcn/ui compatible Figma kit with updated components, 550+ blocks, 10+ templates, 20+ themes, and an AI tool that converts designs into shadcn/ui code.
- [Shadcnblocks.com](https://www.shadcnblocks.com) - A Premium Shadcn Figma UI Kit with components, 500+ pro blocks, shadcn theme variables, light/dark mode and Figma MCP ready.
- [Obra shadcn/ui Pro](https://shadcn.obra.studio/products/obra-shadcn-ui-pro) by [Obra Studio](https://obra.studio/) - Focused on designers who need to get work done — the best designer experience for shadcn/ui within Figma. variable consistency with shadcn, plus custom components, Pro blocks, and a design-to-code plugin.

View File

@@ -4,13 +4,13 @@
"index",
"[Installation](/docs/installation)",
"components-json",
"package-imports",
"theming",
"[Dark Mode](/docs/dark-mode)",
"[RTL](/docs/rtl)",
"[CLI](/docs/cli)",
"monorepo",
"skills",
"v0",
"javascript",
"blocks",
"figma",

View File

@@ -164,3 +164,91 @@ turbo.json
4. **For Tailwind CSS v4, leave the `tailwind` config empty in the `components.json` file.**
By following these requirements, the CLI will be able to install ui components, blocks, libs and hooks to the correct paths and handle imports for you.
<Callout className="mt-6">
`package.json#imports` works well for package-local aliases inside a
workspace, for example inside `packages/ui`. For shared workspace imports such
as `@workspace/ui/components`, keep explicit aliases in `components.json`. The
CLI uses those aliases to route files across workspace boundaries.
</Callout>
## Using `package.json#imports`
For a monorepo that uses package imports and does not rely on
`tsconfig.json` `paths`, use:
- local `#...` aliases for files inside each workspace
- workspace package `exports` for shared imports such as
`@workspace/ui/components`
For example, an app workspace can use local package imports:
```json showLineNumbers title="apps/web/package.json"
{
"name": "web",
"private": true,
"type": "module",
"imports": {
"#components/*": "./src/components/*.tsx",
"#lib/*": "./src/lib/*.ts",
"#hooks/*": "./src/hooks/*.ts"
},
"dependencies": {
"@workspace/ui": "workspace:*"
}
}
```
```json showLineNumbers title="apps/web/components.json"
{
"aliases": {
"components": "#components",
"ui": "@workspace/ui/components",
"lib": "#lib",
"hooks": "#hooks",
"utils": "@workspace/ui/lib/utils"
}
}
```
And the shared UI package can expose its install targets with `exports`:
```json showLineNumbers title="packages/ui/package.json"
{
"name": "@workspace/ui",
"private": true,
"type": "module",
"imports": {
"#components/*": "./src/components/*.tsx",
"#lib/*": "./src/lib/*.ts",
"#hooks/*": "./src/hooks/*.ts"
},
"exports": {
"./globals.css": "./src/styles/globals.css",
"./components/*": "./src/components/*.tsx",
"./lib/*": "./src/lib/*.ts",
"./hooks/*": "./src/hooks/*.ts"
}
}
```
```json showLineNumbers title="packages/ui/components.json"
{
"aliases": {
"components": "#components",
"ui": "#components",
"lib": "#lib",
"hooks": "#hooks",
"utils": "#lib/utils"
}
}
```
In this setup:
- files added from the app to the shared UI package are routed through
`@workspace/ui/...`
- files added inside `packages/ui` use the package-local `#...` aliases
- the shared package must export any path referenced by another workspace
For framework-specific package import setup, see the [package imports guide](/docs/package-imports).

View File

@@ -0,0 +1,234 @@
---
title: Package Imports
description: Configure shadcn/ui with package.json imports.
---
The `shadcn` CLI supports [package imports](https://nodejs.org/api/packages.html#imports)
for installing components, rewriting imports, and resolving third-party
registries.
Package imports let you use private `#...` import aliases from your
`package.json` instead of `compilerOptions.paths` in `tsconfig.json`.
## Example
You configure `imports` in your `package.json`:
```json title="package.json" showLineNumbers
{
"imports": {
"#components/*": "./src/components/*.tsx",
"#lib/*": "./src/lib/*.ts",
"#hooks/*": "./src/hooks/*.ts"
}
}
```
Then import generated components using `#...` specifiers:
```tsx
import { Button } from "#components/ui/button"
import { cn } from "#lib/utils"
```
<Callout className="mt-6">
Package import specifiers must start with `#`. Use TypeScript 5 or later with
`moduleResolution: "bundler"` and `resolvePackageJsonImports: true`.
</Callout>
## App
For Next.js, Vite, and TanStack Start apps that install
components into the same workspace.
<Steps>
### Configure `package.json`
Add imports for the shadcn/ui install targets.
```json title="package.json" showLineNumbers
{
"imports": {
"#components/*": "./src/components/*.tsx",
"#lib/*": "./src/lib/*.ts",
"#hooks/*": "./src/hooks/*.ts"
}
}
```
If your app does not use a `src` directory, remove `src/` from the targets. For
example:
```json title="package.json" showLineNumbers
{
"imports": {
"#components/*": "./components/*.tsx",
"#lib/*": "./lib/*.ts",
"#hooks/*": "./hooks/*.ts"
}
}
```
### Configure TypeScript
Enable package import resolution.
```json title="tsconfig.json" showLineNumbers
{
"compilerOptions": {
"moduleResolution": "bundler",
"resolvePackageJsonImports": true
}
}
```
You do not need `compilerOptions.paths` for these aliases.
### Configure `components.json`
Use the same `#...` roots in `components.json`.
```json title="components.json" showLineNumbers
{
"aliases": {
"components": "#components",
"ui": "#components/ui",
"lib": "#lib",
"hooks": "#hooks",
"utils": "#lib/utils"
}
}
```
The `ui` alias uses `#components/ui`. It is still covered by the
`#components/*` import in `package.json`.
The `utils` alias uses `#lib/utils`. It is covered by `#lib/*`, so you do not
need a separate `#utils` import.
</Steps>
## Monorepo
In a monorepo, use package imports for files inside each package and package
exports for files shared across workspaces.
For an app workspace:
```json title="apps/web/package.json" showLineNumbers
{
"name": "web",
"private": true,
"imports": {
"#components/*": "./src/components/*.tsx",
"#lib/*": "./src/lib/*.ts",
"#hooks/*": "./src/hooks/*.ts"
},
"dependencies": {
"@workspace/ui": "workspace:*"
}
}
```
```json title="apps/web/components.json" showLineNumbers
{
"aliases": {
"components": "#components",
"ui": "@workspace/ui/components",
"lib": "#lib",
"hooks": "#hooks",
"utils": "@workspace/ui/lib/utils"
}
}
```
For the shared UI package:
```json title="packages/ui/package.json" showLineNumbers
{
"name": "@workspace/ui",
"private": true,
"imports": {
"#components/*": "./src/components/*.tsx",
"#lib/*": "./src/lib/*.ts",
"#hooks/*": "./src/hooks/*.ts"
},
"exports": {
"./globals.css": "./src/styles/globals.css",
"./components/*": "./src/components/*.tsx",
"./lib/*": "./src/lib/*.ts",
"./hooks/*": "./src/hooks/*.ts"
}
}
```
```json title="packages/ui/components.json" showLineNumbers
{
"aliases": {
"components": "#components",
"ui": "#components",
"lib": "#lib",
"hooks": "#hooks",
"utils": "#lib/utils"
}
}
```
When you run `add` from `apps/web`, app-local files use `#...` imports and
shared UI files are imported from `@workspace/ui`.
```tsx
import { Button } from "@workspace/ui/components/button"
import { LoginForm } from "#components/login-form"
```
## File extensions
The target pattern in `package.json#imports` controls whether generated imports
include file extensions.
```json title="package.json" showLineNumbers
{
"imports": {
"#components/*": "./src/components/*.tsx"
}
}
```
This generates imports without extensions:
```tsx
import { Button } from "#components/ui/button"
```
If you use a target without the extension:
```json title="package.json" showLineNumbers
{
"imports": {
"#components/*": "./src/components/*"
}
}
```
The generated import keeps the source extension:
```tsx
import { Button } from "#components/ui/button.tsx"
```
For most apps, use the extension in the target pattern.
## Troubleshooting
If TypeScript cannot resolve a `#...` import, check that:
- the specifier starts with `#`
- the `imports` entry is in the nearest `package.json`
- `moduleResolution` is set to `bundler`
- `resolvePackageJsonImports` is enabled
- the matching target exists after components are added
If a component is installed but imports still point to `@/...`, check that
`components.json` uses the same `#...` aliases as your package imports.

View File

@@ -116,7 +116,7 @@ npx shadcn@latest docs combobox
combobox
- docs https://ui.shadcn.com/docs/components/radix/combobox
- examples https://raw.githubusercontent.com/shadcn-ui/ui/refs/heads/main/apps/v4/registry/bases/radix/examples/combobox-example.tsx
- examples https://ui.shadcn.com/code/apps/v4/registry/bases/radix/examples/combobox-example.tsx
- api https://base-ui.com/react/components/combobox
```

View File

@@ -0,0 +1,35 @@
---
title: April 2026 - Partial Preset Apply
description: Apply only the theme or fonts from a preset while keeping your existing components.
date: 2026-04-22
---
You can now selectively apply a preset.
Say someone shares a preset with you and you already have your own components, but you like the theme or the fonts. Now you can apply just that.
Keep your components. Apply only what you want.
```bash
# Apply the full preset.
npx shadcn@latest apply --preset b2D0vQ7G4
# Apply only the theme.
npx shadcn@latest apply --preset b2D0vQ7G4 --only theme
# Apply only the fonts.
npx shadcn@latest apply --preset b2D0vQ7G4 --only font
# Apply theme and fonts.
npx shadcn@latest apply --preset b2D0vQ7G4 --only theme,font
```
The default behavior is unchanged. Running `shadcn apply --preset <preset>` still applies the full preset.
Partial preset apply currently supports `theme` and `font`.
<Button asChild size="sm">
<Link href="/create" className="mt-6 no-underline!">
Try a Preset
</Link>
</Button>

View File

@@ -0,0 +1,24 @@
---
title: April 2026 - Pointer Cursor
description: Add cursor pointer behavior for buttons during project setup.
date: 2026-04-25
---
You can now enable `cursor: pointer` for buttons when initializing a project.
```bash
npx shadcn@latest init --pointer
```
This adds the following CSS to your global CSS file:
```css title="globals.css"
@layer base {
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
```
The `--pointer` option is not part of preset codes. It is applied as a project setup option, similar to `--rtl`.

View File

@@ -0,0 +1,93 @@
---
title: April 2026 - shadcn preset
description: Decode, share, open, and resolve preset codes from the shadcn CLI.
date: 2026-04-28
---
We added `shadcn preset` commands for working with preset codes.
## Decode a preset
You can decode a preset code to see exactly what it contains:
```bash
npx shadcn@latest preset decode b5owWMfJ8l
```
```txt
Preset
code b5owWMfJ8l
version b
style mira
baseColor mauve
theme mauve
chartColor amber
iconLibrary hugeicons
font inter
fontHeading oxanium
radius large
menuAccent subtle
menuColor inverted-translucent
url https://ui.shadcn.com/create?preset=b5owWMfJ8l
```
## Resolve from a project
Use `preset resolve` in an existing project to see the preset that matches your current configuration.
```bash
npx shadcn@latest preset resolve
```
```txt
Preset
code b5Kc6P0Vc
version b
style luma
baseColor olive
theme lime
chartColor sky
iconLibrary hugeicons
font geist
fontHeading inherit
radius default
menuAccent subtle
menuColor default
url https://ui.shadcn.com/create?preset=b5Kc6P0Vc
```
It works with monorepos too:
```bash
npx shadcn@latest preset resolve -c apps/web
```
## Share or open
Use `preset url` when you need a shareable link:
```bash
npx shadcn@latest preset url b5owWMfJ8l
```
```txt
https://ui.shadcn.com/create?preset=b5owWMfJ8l
```
Use `preset open` to open the preset on shadcn/create for customization:
```bash
npx shadcn@latest preset open b5owWMfJ8l
```
```txt
Opening https://ui.shadcn.com/create?preset=b5owWMfJ8l in your browser.
```
This makes presets easier to inspect, share, and hand off to coding agents without manually decoding codes or building URLs.
<Button asChild size="sm">
<Link href="/create" className="mt-6 no-underline!">
Try a Preset
</Link>
</Button>

View File

@@ -0,0 +1,37 @@
---
title: April 2026 - Introducing Sera
description: Minimal. Editorial. Typographic. Underline Controls and Uppercase Headings. Shaped by Print Design Principles.
date: 2026-04-16
---
Introducing Sera, a new shadcn/ui style. Minimal. Editorial. Typographic. Underline Controls and Uppercase Headings. Shaped by Print Design Principles.
<a href="/create?preset=b4xFeBLg4O">
<Image
src="/images/sera-01-light.png"
width="2160"
height="1832"
alt="Sera style preview"
className="mt-6 w-full overflow-hidden rounded-lg border dark:hidden"
/>
<Image
src="/images/sera-01-dark.png"
width="2160"
height="1832"
alt="Sera style preview"
className="mt-6 hidden w-full overflow-hidden rounded-lg border shadow-sm dark:block"
/>
<span className="sr-only">Try Sera in shadcn/create</span>
</a>
Sera is a typography-first style built on print design principles. It pairs serif headings with sans-serif body text, uses square corners, uppercase tracking, and underlined controls to create an editorial feel for your app.
Like the other new styles, Sera goes beyond theming. It changes the geometry, spacing, and feel of the components so your app starts from a different visual baseline.
Available now in [shadcn/create](/create) for both Radix and Base UI.
<Button asChild size="sm">
<Link href="/create?preset=b4xFeBLg4O" className="mt-6 no-underline!">
Try Sera
</Link>
</Button>

View File

@@ -0,0 +1,21 @@
---
title: April 2026 - shadcn apply
description: Switch presets in existing projects without starting over.
date: 2026-04-08
---
We added `shadcn apply` so you can switch presets in an existing project without starting over.
When you run `npx shadcn@latest apply` in an existing project, we apply a new preset, reinstall your existing components, and update your theme, colors, CSS variables, fonts, and icons.
```bash
npx shadcn@latest apply --preset b2D0vQ7G4
```
The CLI keeps the current base and RTL settings from your existing project, even when the preset URL was generated with different values.
<Button asChild size="sm">
<Link href="/create" className="mt-6 no-underline!">
Try a Preset
</Link>
</Button>

View File

@@ -0,0 +1,63 @@
---
title: May 2026 - Package Imports and Target Aliases
description: Configure shadcn/ui with package.json imports and portable registry target aliases.
date: 2026-05-05
---
We've added support for package imports and aliases in `files.target` in `shadcn@4.7.0`.
## Package imports
The `shadcn` CLI now supports `package.json#imports` for installing components,
rewriting imports, and resolving third-party registries. You can use private
`#...` import aliases from your `package.json` instead of relying only on
`compilerOptions.paths` in `tsconfig.json`.
```json title="package.json" showLineNumbers
{
"imports": {
"#components/*": "./src/components/*.tsx",
"#lib/*": "./src/lib/*.ts",
"#hooks/*": "./src/hooks/*.ts"
}
}
```
Then use the same roots in `components.json`:
```json title="components.json" showLineNumbers
{
"aliases": {
"components": "#components",
"ui": "#components/ui",
"lib": "#lib",
"hooks": "#hooks",
"utils": "#lib/utils"
}
}
```
This also works in monorepos where app-local files use package imports and
shared UI files are imported from workspace package exports.
See the [package imports guide](/docs/package-imports) for setup details.
## Target aliases
Registry items can now use target aliases in `files[].target` to install files
under the user's configured shadcn directories. For example, the following registry item will install the `prompt-input.tsx` file under the `ui/ai` directory.
```json title="example.json" showLineNumbers
{
"files": [
{
"path": "registry/default/ai/prompt-input.tsx",
"type": "registry:ui",
"target": "@ui/ai/prompt-input.tsx"
}
]
}
```
See the [registry examples](/docs/registry/examples#target-placeholders) for
more details.

View File

@@ -0,0 +1,120 @@
---
title: May 2026 - Registry Include and Validate
description: Organize and validate source registries.
date: 2026-05-20
---
This release adds two updates for registry authors:
- `include` for composing large source registries from multiple `registry.json`
files.
- `shadcn registry validate` for checking source registries before publishing.
This makes it easier to maintain source and dynamic registries without keeping
one large `registry.json` file by hand.
Registry authors can now organize a large source registry across multiple
`registry.json` files and compose them with `shadcn build`.
```txt /registry.json/
registry.json
components
└── ui
├── button.tsx
├── input.tsx
└── registry.json
hooks
├── registry.json
├── use-media-query.ts
└── use-toggle.ts
```
{/* prettier-ignore */}
```json title="registry.json" showLineNumbers {6-7}
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme",
"homepage": "https://acme.com",
"include": [
"components/ui/registry.json",
"hooks/registry.json"
]
}
```
Included `registry.json` files are valid registry files for composition and may
omit `name` and `homepage`. Only the root `registry.json` must define the
registry metadata.
```json title="components/ui/registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"items": [
{
"name": "button",
"type": "registry:ui",
"files": [
{
"path": "button.tsx",
"type": "registry:ui"
}
]
}
]
}
```
## Build output
`shadcn build` resolves included registries and writes a flattened
`registry.json` without `include`. Item file paths are preserved from the root
registry, so a file declared in `components/ui/registry.json` is written as
`components/ui/button.tsx` in the built registry item.
## Validate your registry
You can now validate a source registry before publishing or serving it.
```bash
npx shadcn registry validate
```
Validation runs against the source registry files directly. You do not need to
run `shadcn build` first.
The command checks the root `registry.json`, included registry files, item
schema errors, duplicate item names, include rules, and local item file paths.
Validation reports all actionable errors it can find in one run.
## Registry loaders
The `shadcn/registry` package also exports `loadRegistry` and
`loadRegistryItem` for dynamic registry routes.
```ts title="app/r/registry.json/route.ts" showLineNumbers
import { loadRegistry } from "shadcn/registry"
export async function GET() {
const registry = await loadRegistry()
return Response.json(registry)
}
```
```ts title="app/r/[name].json/route.ts" showLineNumbers
import { loadRegistryItem } from "shadcn/registry"
export async function GET(
_: Request,
{ params }: { params: Promise<{ name: string }> }
) {
const { name } = await params
const item = await loadRegistryItem(name)
return Response.json(item)
}
```
See the [registry.json documentation](/docs/registry/registry-json#include) and
[getting started guide](/docs/registry/getting-started#structure-your-registry)
for more details.

View File

@@ -66,6 +66,8 @@ Tailwind v4 [switched](https://tailwindcss.com/docs/upgrade-guide#buttons-use-th
If you want to keep the `cursor: pointer` behavior, add the following code to your CSS file:
You can also enable this during project setup with `npx shadcn@latest init --pointer`.
```css showLineNumbers title="globals.css"
@layer base {
button:not(:disabled),

View File

@@ -156,7 +156,7 @@ The `Field` family is designed for composing accessible forms. A typical field i
## Form
See the [Form](/docs/forms) documentation for building forms with the `Field` component and [React Hook Form](/docs/forms/react-hook-form) or [Tanstack Form](/docs/forms/tanstack-form).
See the [Form](/docs/forms) documentation for building forms with the `Field` component and [React Hook Form](/docs/forms/react-hook-form), [Tanstack Form](/docs/forms/tanstack-form), or [Formisch](/docs/forms/formisch).
## Examples

View File

@@ -129,3 +129,9 @@ To enable RTL support in shadcn/ui, see the [RTL configuration guide](/docs/rtl)
## API Reference
See the [Base UI Toggle Group](https://base-ui.com/react/components/toggle-group#api-reference) documentation.
## Changelog
### 2026-05-17 Default Spacing
Changed the default `spacing` from `0` to `2` so toggle groups render with space between items by default. Use `spacing={0}` for connected items.

View File

@@ -66,6 +66,8 @@ Tailwind v4 [switched](https://tailwindcss.com/docs/upgrade-guide#buttons-use-th
If you want to keep the `cursor: pointer` behavior, add the following code to your CSS file:
You can also enable this during project setup with `npx shadcn@latest init --pointer`.
```css showLineNumbers title="globals.css"
@layer base {
button:not(:disabled),

View File

@@ -156,7 +156,7 @@ The `Field` family is designed for composing accessible forms. A typical field i
## Form
See the [Form](/docs/forms) documentation for building forms with the `Field` component and [React Hook Form](/docs/forms/react-hook-form) or [Tanstack Form](/docs/forms/tanstack-form).
See the [Form](/docs/forms) documentation for building forms with the `Field` component and [React Hook Form](/docs/forms/react-hook-form), [Tanstack Form](/docs/forms/tanstack-form), or [Formisch](/docs/forms/formisch).
## Examples

View File

@@ -129,3 +129,9 @@ To enable RTL support in shadcn/ui, see the [RTL configuration guide](/docs/rtl)
## API Reference
See the [Radix Toggle Group](https://www.radix-ui.com/docs/primitives/components/toggle-group#api-reference) documentation.
## Changelog
### 2026-05-17 Default Spacing
Changed the default `spacing` from `0` to `2` so toggle groups render with space between items by default. Use `spacing={0}` for connected items.

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
{
"title": "Dark mode",
"pages": ["index", "next", "vite", "astro", "remix"]
"pages": ["index", "next", "vite", "astro", "remix", "tanstack-start"]
}

View File

@@ -0,0 +1,191 @@
---
title: TanStack Start
description: Adding dark mode to your TanStack Start app.
---
<Steps>
### Create a theme provider
TanStack Start uses `ScriptOnce` from `@tanstack/react-router` to inject a script that runs before React hydrates, preventing flash of unstyled content (FOUC).
```tsx title="components/theme-provider.tsx" showLineNumbers
import { createContext, useContext, useEffect, useState } from "react"
import { ScriptOnce } from "@tanstack/react-router"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
function getThemeScript(storageKey: string, defaultTheme: Theme) {
const key = JSON.stringify(storageKey)
const fallback = JSON.stringify(defaultTheme)
return `(function(){try{var t=localStorage.getItem(${key});if(t!=='light'&&t!=='dark'&&t!=='system'){t=${fallback}}var d=matchMedia('(prefers-color-scheme: dark)').matches;var r=t==='system'?(d?'dark':'light'):t;var e=document.documentElement;e.classList.add(r);e.style.colorScheme=r}catch(e){}})();`
}
const ThemeProviderContext = createContext<ThemeProviderState>({
theme: "system",
setTheme: () => {},
})
function applyTheme(theme: Theme) {
const root = document.documentElement
root.classList.remove("light", "dark")
const resolved =
theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: theme
root.classList.add(resolved)
root.style.colorScheme = resolved
}
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "theme",
}: ThemeProviderProps) {
const [theme, setThemeState] = useState<Theme>(defaultTheme)
const [mounted, setMounted] = useState(false)
useEffect(() => {
const stored = localStorage.getItem(storageKey)
setThemeState(
stored === "light" || stored === "dark" || stored === "system"
? stored
: defaultTheme
)
setMounted(true)
}, [defaultTheme, storageKey])
useEffect(() => {
if (!mounted) return
applyTheme(theme)
}, [theme, mounted])
useEffect(() => {
if (!mounted || theme !== "system") return
const media = window.matchMedia("(prefers-color-scheme: dark)")
const onChange = () => applyTheme("system")
media.addEventListener("change", onChange)
return () => media.removeEventListener("change", onChange)
}, [theme, mounted])
const setTheme = (next: Theme) => {
localStorage.setItem(storageKey, next)
setThemeState(next)
}
return (
<ThemeProviderContext value={{ theme, setTheme }}>
<ScriptOnce>{getThemeScript(storageKey, defaultTheme)}</ScriptOnce>
{children}
</ThemeProviderContext>
)
}
export function useTheme() {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}
```
### Wrap your root layout
Add the `ThemeProvider` to your root layout and add the `suppressHydrationWarning` prop to the `html` tag.
```tsx {8,19,24-26} title="src/routes/__root.tsx" showLineNumbers
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from "@tanstack/react-router"
import { ThemeProvider } from "@/components/theme-provider"
export const Route = createRootRoute({
head: () => ({
// ...
}),
component: RootComponent,
})
function RootComponent() {
return (
<html lang="en" suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body>
<ThemeProvider defaultTheme="system" storageKey="theme">
<Outlet />
</ThemeProvider>
<Scripts />
</body>
</html>
)
}
```
### Add a mode toggle
Place a mode toggle on your site to toggle between light and dark mode.
```tsx title="components/mode-toggle.tsx" showLineNumbers
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useTheme } from "@/components/theme-provider"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
```
</Steps>

View File

@@ -0,0 +1,669 @@
---
title: Formisch
description: Build forms in React using Formisch and Valibot.
links:
doc: https://formisch.dev
---
import { InfoIcon } from "lucide-react"
This guide covers building forms with [Formisch](https://formisch.dev), the lightweight, schema-first, and fully type-safe form library for React. We'll create forms with the `<Field />` component, validate them with Valibot schemas, handle errors, and ensure accessibility.
## Demo
We'll build the following form. It has a simple text input and a textarea. On submit, we'll validate the form data and display any errors.
<Callout icon={<InfoIcon />}>
**Note:** For the purpose of this demo, we have intentionally disabled browser
validation to show how schema validation and form errors work in Formisch. It
is recommended to add basic browser validation in your production code.
</Callout>
<ComponentPreview
name="form-formisch-demo"
className="sm:[&_.preview]:h-[700px]"
chromeLessOnMobile
/>
## Approach
This form leverages Formisch for headless, schema-first form handling. We'll build our form using the `<Field />` component, which gives you **complete flexibility over the markup and styling**.
- Uses Formisch's `useForm` hook for form state management.
- `<Form />` component to wrap the native `<form>` element with submit handling.
- `<Field />` render-prop component for controlled inputs.
- Schema validation using [Valibot](https://valibot.dev).
- Type-safe field paths inferred from the schema.
## Form Methods
Formisch exposes form operations as **top-level functions** rather than methods on a form object. Import only what you need:
```ts
import { getInput, insert, reset, submit } from "@formisch/react"
```
Every method follows the same signature: the **first parameter is always the form store**, and the **second parameter (if necessary) is always a config object**.
```ts
// Read a field value
const email = getInput(form, { path: ["email"] })
// Reset the form with new initial values
reset(form, { initialInput: { email: "", password: "" } })
// Move an item in a field array
move(form, { path: ["items"], from: 0, to: 3 })
```
This design keeps the API flexible and consistent across all methods. You'll see the same `(form, config)` shape used throughout this guide for reading state (`getInput`, `getErrors`), writing state (`setInput`, `setErrors`), form control (`submit`, `validate`, `focus`), and array operations (`insert`, `remove`, `move`, `swap`, `replace`). See the [full methods reference](https://formisch.dev/react/guides/form-methods) for details.
## Anatomy
Here's a basic example of a form using the `<Field />` component from Formisch and the shadcn `<Field />` component.
```tsx showLineNumbers {3-21}
<Form of={form} onSubmit={handleSubmit}>
<FieldGroup>
<FormischField of={form} path={["title"]}>
{(field) => (
<Field data-invalid={field.errors !== null}>
<FieldLabel htmlFor="form-title">Bug Title</FieldLabel>
<Input
{...field.props}
id="form-title"
value={field.input}
aria-invalid={field.errors !== null}
placeholder="Login button not working on mobile"
autoComplete="off"
/>
<FieldDescription>
Provide a concise title for your bug report.
</FieldDescription>
{field.errors && (
<FieldError errors={field.errors.map((message) => ({ message }))} />
)}
</Field>
)}
</FormischField>
</FieldGroup>
</Form>
```
<Callout icon={<InfoIcon />}>
**Note:** Formisch ships its own `Field` component. To avoid a name clash with
the shadcn `Field`, the examples below import the Formisch one as
`FormischField` and keep the shadcn `Field` under its original name. In your
own code you can alias either side — just be consistent.
</Callout>
## Form
### Create a form schema
We'll start by defining the shape of our form using a Valibot schema. Formisch infers all input and output types directly from this schema.
```tsx showLineNumbers title="form.tsx"
import * as v from "valibot"
const FormSchema = v.object({
title: v.pipe(
v.string(),
v.minLength(5, "Bug title must be at least 5 characters."),
v.maxLength(32, "Bug title must be at most 32 characters.")
),
description: v.pipe(
v.string(),
v.minLength(20, "Description must be at least 20 characters."),
v.maxLength(100, "Description must be at most 100 characters.")
),
})
```
### Set up the form
Next, we'll use the `useForm` hook from Formisch to create our form instance. The schema is passed directly to `useForm` — there is no resolver step.
```tsx showLineNumbers title="form.tsx" {1-2,21-25}
import { Form, Field as FormischField, useForm } from "@formisch/react"
import type { SubmitHandler } from "@formisch/react"
import * as v from "valibot"
const FormSchema = v.object({
title: v.pipe(
v.string(),
v.minLength(5, "Bug title must be at least 5 characters."),
v.maxLength(32, "Bug title must be at most 32 characters.")
),
description: v.pipe(
v.string(),
v.minLength(20, "Description must be at least 20 characters."),
v.maxLength(100, "Description must be at most 100 characters.")
),
})
export function BugReportForm() {
const form = useForm({
schema: FormSchema,
initialInput: {
title: "",
description: "",
},
})
const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
// Do something with the validated form values.
console.log(output)
}
return (
<Form of={form} onSubmit={handleSubmit}>
{/* ... */}
{/* Build the form here */}
{/* ... */}
</Form>
)
}
```
The `<Form />` component wraps a native `<form>` element. It calls `event.preventDefault()`, runs validation, and only invokes `onSubmit` when the data is valid. The `output` you receive is fully typed from the schema.
### Build the form
We can now build the form using the `<Field />` component from Formisch and the shadcn `<Field />` component.
<ComponentSource
src="/registry/new-york-v4/examples/form-formisch-demo.tsx"
title="form.tsx"
/>
### Done
That's it. You now have a fully accessible form with client-side validation.
When you submit the form, the `handleSubmit` function will be called with the validated form data. If the form data is invalid, Formisch will populate `field.errors` for each invalid field and the UI will display them.
## Validation
### Client-side Validation
Formisch validates your form data using the Valibot schema you pass to `useForm`. There is no resolver — the schema is the single source of truth for both runtime validation and static types.
```tsx showLineNumbers title="form.tsx" {1,3-6,11}
import { useForm } from "@formisch/react"
const FormSchema = v.object({
title: v.string(),
description: v.optional(v.string()),
})
export function ExampleForm() {
const form = useForm({
schema: FormSchema,
initialInput: {
title: "",
description: "",
},
})
}
```
### Validation Modes
Formisch separates the **first** validation from **subsequent** validations. You configure them with the `validate` and `revalidate` options on `useForm`.
```tsx showLineNumbers title="form.tsx" {3-4}
const form = useForm({
schema: FormSchema,
validate: "blur",
revalidate: "input",
})
```
| Option | Value | Description |
| ------------ | ----------- | --------------------------------------------------------------- |
| `validate` | `"submit"` | Validate on form submission (default). |
| `validate` | `"blur"` | Validate when a field loses focus. |
| `validate` | `"input"` | Validate on every input change. |
| `validate` | `"initial"` | Validate immediately on form creation. |
| `revalidate` | `"input"` | Revalidate on every input change after the first run (default). |
| `revalidate` | `"blur"` | Revalidate on blur after the first run. |
| `revalidate` | `"submit"` | Revalidate only on form submission. |
## Displaying Errors
Display errors next to the field using `<FieldError />`. Formisch returns errors as an array of strings, so map them to the shape `<FieldError />` expects. For styling and accessibility:
- Add the `data-invalid` prop to the `<Field />` component.
- Add the `aria-invalid` prop to the form control such as `<Input />`, `<SelectTrigger />`, `<Checkbox />`, etc.
```tsx showLineNumbers title="form.tsx" {3,10,12-14}
<FormischField of={form} path={["email"]}>
{(field) => (
<Field data-invalid={field.errors !== null}>
<FieldLabel htmlFor="form-email">Email</FieldLabel>
<Input
{...field.props}
id="form-email"
value={field.input}
type="email"
aria-invalid={field.errors !== null}
/>
{field.errors && (
<FieldError errors={field.errors.map((message) => ({ message }))} />
)}
</Field>
)}
</FormischField>
```
## Working with Different Field Types
Formisch exposes two ways to bind a field to an element:
- **Native HTML elements** (like `<Input />` and `<Textarea />`) — spread `field.props` and provide `value={field.input}`. Formisch wires up `name`, `ref`, `onChange`, `onBlur`, and `onFocus` for you.
- **Component-library inputs** (like Radix-based `<Select />`, `<Checkbox />`, `<RadioGroup />`, `<Switch />`) — read the value from `field.input` and call `field.onChange(value)` to update it.
### Input
- For input fields, spread `field.props` and provide `value={field.input}`.
- To show errors, add the `aria-invalid` prop to the `<Input />` component and the `data-invalid` prop to the `<Field />` component.
<ComponentPreview
name="form-formisch-input"
className="sm:[&_.preview]:h-[700px]"
chromeLessOnMobile
/>
```tsx showLineNumbers title="form.tsx" {5-8}
<FormischField of={form} path={["username"]}>
{(field) => (
<Field data-invalid={field.errors !== null}>
<FieldLabel htmlFor="form-username">Username</FieldLabel>
<Input
{...field.props}
id="form-username"
value={field.input}
aria-invalid={field.errors !== null}
/>
{field.errors && (
<FieldError errors={field.errors.map((message) => ({ message }))} />
)}
</Field>
)}
</FormischField>
```
### Textarea
- For textarea fields, spread `field.props` and provide `value={field.input}`.
- To show errors, add the `aria-invalid` prop to the `<Textarea />` component and the `data-invalid` prop to the `<Field />` component.
<ComponentPreview
name="form-formisch-textarea"
className="sm:[&_.preview]:h-[700px]"
chromeLessOnMobile
/>
```tsx showLineNumbers title="form.tsx" {7-10}
<FormischField of={form} path={["about"]}>
{(field) => (
<Field data-invalid={field.errors !== null}>
<FieldLabel htmlFor="form-about">More about you</FieldLabel>
<Textarea
{...field.props}
id="form-about"
value={field.input}
aria-invalid={field.errors !== null}
placeholder="I'm a software engineer..."
className="min-h-[120px]"
/>
<FieldDescription>
Tell us more about yourself. This will be used to help us personalize
your experience.
</FieldDescription>
{field.errors && (
<FieldError errors={field.errors.map((message) => ({ message }))} />
)}
</Field>
)}
</FormischField>
```
### Select
- For select components, read `field.input` and call `field.onChange` from `<Select />`'s `onValueChange`.
- To show errors, add the `aria-invalid` prop to the `<SelectTrigger />` component and the `data-invalid` prop to the `<Field />` component.
<ComponentPreview
name="form-formisch-select"
className="sm:[&_.preview]:h-[500px]"
chromeLessOnMobile
/>
```tsx showLineNumbers title="form.tsx" {15-19}
<FormischField of={form} path={["language"]}>
{(field) => (
<Field orientation="responsive" data-invalid={field.errors !== null}>
<FieldContent>
<FieldLabel htmlFor="form-language">Spoken Language</FieldLabel>
<FieldDescription>
For best results, select the language you speak.
</FieldDescription>
{field.errors && (
<FieldError errors={field.errors.map((message) => ({ message }))} />
)}
</FieldContent>
<Select value={field.input} onValueChange={field.onChange}>
<SelectTrigger
id="form-language"
aria-invalid={field.errors !== null}
className="min-w-[120px]"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="item-aligned">
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
</Field>
)}
</FormischField>
```
### Checkbox
- For checkbox arrays, read `field.input` and update it from `onCheckedChange` using `field.onChange`.
- To show errors, add the `aria-invalid` prop to the `<Checkbox />` component and the `data-invalid` prop to the `<Field />` component.
- Remember to add `data-slot="checkbox-group"` to the `<FieldGroup />` component for proper styling and spacing.
<ComponentPreview
name="form-formisch-checkbox"
className="sm:[&_.preview]:h-[700px]"
chromeLessOnMobile
/>
```tsx showLineNumbers title="form.tsx" {16,19-25}
<FormischField of={form} path={["tasks"]}>
{(field) => (
<FieldSet>
<FieldLegend variant="label">Tasks</FieldLegend>
<FieldDescription>
Get notified when tasks you&apos;ve created have updates.
</FieldDescription>
<FieldGroup data-slot="checkbox-group">
{tasks.map((task) => (
<Field
key={task.id}
orientation="horizontal"
data-invalid={field.errors !== null}
>
<Checkbox
id={`form-checkbox-${task.id}`}
aria-invalid={field.errors !== null}
checked={field.input?.includes(task.id) ?? false}
onCheckedChange={(checked) => {
const current = field.input ?? []
field.onChange(
checked === true
? [...current, task.id]
: current.filter((value) => value !== task.id)
)
}}
/>
<FieldLabel
htmlFor={`form-checkbox-${task.id}`}
className="font-normal"
>
{task.label}
</FieldLabel>
</Field>
))}
</FieldGroup>
{field.errors && (
<FieldError errors={field.errors.map((message) => ({ message }))} />
)}
</FieldSet>
)}
</FormischField>
```
### Radio Group
- For radio groups, read `field.input` and call `field.onChange` from `onValueChange`.
- To show errors, add the `aria-invalid` prop to the `<RadioGroupItem />` component and the `data-invalid` prop to the `<Field />` component.
<ComponentPreview
name="form-formisch-radiogroup"
className="sm:[&_.preview]:h-[700px]"
chromeLessOnMobile
/>
```tsx showLineNumbers title="form.tsx" {9-13,21}
<FormischField of={form} path={["plan"]}>
{(field) => (
<FieldSet>
<FieldLegend>Plan</FieldLegend>
<FieldDescription>
You can upgrade or downgrade your plan at any time.
</FieldDescription>
<RadioGroup value={field.input} onValueChange={field.onChange}>
{plans.map((plan) => (
<FieldLabel key={plan.id} htmlFor={`form-radiogroup-${plan.id}`}>
<Field
orientation="horizontal"
data-invalid={field.errors !== null}
>
<FieldContent>
<FieldTitle>{plan.title}</FieldTitle>
<FieldDescription>{plan.description}</FieldDescription>
</FieldContent>
<RadioGroupItem
value={plan.id}
id={`form-radiogroup-${plan.id}`}
aria-invalid={field.errors !== null}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
{field.errors && (
<FieldError errors={field.errors.map((message) => ({ message }))} />
)}
</FieldSet>
)}
</FormischField>
```
### Switch
- For switches, read `field.input` and call `field.onChange` from `onCheckedChange`.
- To show errors, add the `aria-invalid` prop to the `<Switch />` component and the `data-invalid` prop to the `<Field />` component.
<ComponentPreview
name="form-formisch-switch"
className="sm:[&_.preview]:h-[500px]"
chromeLessOnMobile
/>
```tsx showLineNumbers title="form.tsx" {15-19}
<FormischField of={form} path={["twoFactor"]}>
{(field) => (
<Field orientation="horizontal" data-invalid={field.errors !== null}>
<FieldContent>
<FieldLabel htmlFor="form-twoFactor">
Multi-factor authentication
</FieldLabel>
<FieldDescription>
Enable multi-factor authentication to secure your account.
</FieldDescription>
{field.errors && (
<FieldError errors={field.errors.map((message) => ({ message }))} />
)}
</FieldContent>
<Switch
id="form-twoFactor"
checked={field.input ?? false}
onCheckedChange={field.onChange}
aria-invalid={field.errors !== null}
/>
</Field>
)}
</FormischField>
```
### Complex Forms
Here is an example of a more complex form with multiple fields and validation.
<ComponentPreview
name="form-formisch-complex"
className="sm:[&_.preview]:h-[1300px]"
chromeLessOnMobile
/>
## Resetting the Form
Formisch exposes a top-level `reset` function. Pass the form store to reset it to its initial input.
```tsx showLineNumbers
<Button type="button" variant="outline" onClick={() => reset(form)}>
Reset
</Button>
```
You can also reset to new initial values, or reset while keeping the user's current input:
```tsx showLineNumbers
// Reset to a fresh set of initial values
reset(form, { initialInput: { title: "", description: "" } })
// Sync the baseline to new server data, but keep the user's edits
reset(form, { initialInput: serverData, keepInput: true })
```
## Array Fields
Formisch provides a `<FieldArray />` component and a set of helper functions for managing dynamic array fields. Use it whenever you need to add, remove, or reorder items.
<ComponentPreview
name="form-formisch-array"
className="sm:[&_.preview]:h-[700px]"
chromeLessOnMobile
/>
### Using FieldArray
`<FieldArray />` follows the same render-prop pattern as `<Field />`. Its `items` array contains a stable key per item that you should use as the React `key`.
```tsx showLineNumbers title="form.tsx" {1,7-22}
import {
Field as FormischField,
FieldArray,
insert,
remove,
} from "@formisch/react"
export function ExampleForm() {
// ... form config
return (
<FieldArray of={form} path={["emails"]}>
{(fieldArray) => (
<FieldGroup className="gap-4">
{fieldArray.items.map((item, index) => (
<FormischField
key={item}
of={form}
path={["emails", index, "address"]}
>
{(field) => /* ... */}
</FormischField>
))}
</FieldGroup>
)}
</FieldArray>
)
}
```
### Array Field Structure
Wrap your array fields in a `<FieldSet />` with a `<FieldLegend />` and `<FieldDescription />`.
```tsx showLineNumbers title="form.tsx"
<FieldSet className="gap-4">
<FieldLegend variant="label">Email Addresses</FieldLegend>
<FieldDescription>
Add up to 5 email addresses where we can contact you.
</FieldDescription>
<FieldGroup className="gap-4">{/* Array items go here */}</FieldGroup>
</FieldSet>
```
### Adding Items
Use the `insert` function to add new items to the array. By default new items are appended to the end. You can also pass an `at` index to insert at a specific position.
```tsx showLineNumbers title="form.tsx"
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
insert(form, { path: ["emails"], initialInput: { address: "" } })
}
disabled={fieldArray.items.length >= 5}
>
Add Email Address
</Button>
```
### Removing Items
Use the `remove` function with an `at` index to remove items from the array.
```tsx showLineNumbers title="form.tsx"
import { remove } from "@formisch/react"
{
fieldArray.items.length > 1 && (
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
variant="ghost"
size="icon-xs"
onClick={() => remove(form, { path: ["emails"], at: index })}
aria-label={`Remove email ${index + 1}`}
>
<XIcon />
</InputGroupButton>
</InputGroupAddon>
)
}
```
Formisch also exposes `move`, `swap`, and `replace` for reordering and replacing items. They follow the same `(form, config)` signature.
### Array Validation
Use Valibot's `array` and pipeline validators to constrain array fields.
```tsx showLineNumbers title="form.tsx"
const FormSchema = v.object({
emails: v.pipe(
v.array(
v.object({
address: v.pipe(
v.string(),
v.nonEmpty("Enter an email address."),
v.email("Enter a valid email address.")
),
})
),
v.minLength(1, "Add at least one email address."),
v.maxLength(5, "You can add up to 5 email addresses.")
),
})
```

View File

@@ -25,6 +25,24 @@ Start by selecting your framework. Then follow the instructions to learn how to
</svg>
<p className="mt-2 font-medium">TanStack Form</p>
</LinkedCard>
<LinkedCard href="/docs/forms/formisch">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
className="size-10"
fill="currentColor"
>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="20"
d="M90.9 291.7 49 299.4v125.5l193.3 63.8 221-63.8V304.7l-62-13.7m-352.2 8.6 193.2 53.6V488m221-183.2-221.1 48.4m-171-67.7 147.5 47 212.7-48.3L437 76 271.9 25 72.1 66.3Zm1-219.3L220.5 118l-1.8 214m1.8-214.3 216.2-41.6m-84.4 159.6-.9 1.9c-5.2 11.3-16.2 34.8-33.5 35.5h-1a22 22 0 0 1-9.7-2.2 26 26 0 0 1-7.4-5.5c-4.3-4.4-7.4-10-10-14.5q-1.2-2.4-2.7-4.7a54 54 0 0 0 21.7 4.1 74 74 0 0 0 23.6-4 70 70 0 0 0 11.4-5 64 64 0 0 0 8.5-5.6M247.6 168c19.5-20.8 34.8-12.3 36-18.4 1.5-7-12.2-7.7-22.2-2.6s-18 17.7-13.8 21m101.2-33.2c1.6 2.6 8.7-1.6 19.5-1.2s15.8 5.7 18 4.1-4.6-14.7-17.1-15.2-22 9.7-20.4 12.3m21.2 16.3c9.4.6 17.1 12.8 16.1 27.6s-10.4 25.9-19.8 25.2-17.1-12.9-16.1-27.7 10.4-25.8 19.8-25.1m-97.4 19c11 1.2 19 15.1 17.4 30.5s-12.3 27.4-23.4 26.2-19-15-17.4-30.5 12.3-27.3 23.4-26.2"
/>
</svg>
<p className="mt-2 font-medium">Formisch</p>
</LinkedCard>
<LinkedCard href="#" className="border border-dashed bg-transparent">
<svg
role="img"

View File

@@ -1,3 +1,3 @@
{
"pages": ["react-hook-form", "tanstack-form"]
"pages": ["react-hook-form", "tanstack-form", "formisch"]
}

View File

@@ -19,9 +19,11 @@ Add the following dependencies to your project:
npm install shadcn class-variance-authority clsx tailwind-merge lucide-react tw-animate-css
```
### Configure path aliases
### Configure import aliases
Configure the path aliases in your `tsconfig.json` file.
Choose one of the following alias setups.
#### Option A: `tsconfig.json` paths
```json {3-6} title="tsconfig.json" showLineNumbers
{
@@ -34,7 +36,31 @@ Configure the path aliases in your `tsconfig.json` file.
}
```
The `@` alias is a preference. You can use other aliases if you want.
#### Option B: `package.json#imports`
```json title="package.json" showLineNumbers
{
"imports": {
"#components/*": "./src/components/*.tsx",
"#lib/*": "./src/lib/*.ts",
"#hooks/*": "./src/hooks/*.ts"
}
}
```
```json title="tsconfig.json" showLineNumbers
{
"compilerOptions": {
"moduleResolution": "bundler",
"resolvePackageJsonImports": true
}
}
```
The `@` alias is a preference. You can use other aliases if you want. If you
use `package.json#imports`, keep the matching alias roots in `components.json`.
See the <Link href="/docs/package-imports">package imports guide</Link> for
framework-specific setup.
### Configure styles
@@ -211,6 +237,20 @@ Create a `components.json` file in the root of your project.
}
```
If you're using `package.json#imports`, use the corresponding `#...` aliases instead:
```json title="components.json" showLineNumbers
{
"aliases": {
"components": "#components",
"utils": "#lib/utils",
"ui": "#components/ui",
"lib": "#lib",
"hooks": "#hooks"
}
}
```
### That's it
You can now start adding components to your project.

View File

@@ -309,6 +309,94 @@ A `registry:hook` item is a custom React hook.
}
```
## Target Placeholders
Use `files[].target` placeholders when a registry item should install files
under the user's configured shadcn directories. The available placeholders are
`@components/`, `@ui/`, `@lib/` and `@hooks/`.
The placeholders are resolved from `components.json`, so the same registry item
works in projects using `@/`, custom TypeScript aliases, package imports or
workspace package exports.
Anything after the placeholder is preserved. For example,
`@ui/ai/prompt-input.tsx` installs under the user's configured `ui` directory
at `ai/prompt-input.tsx`.
```json title="alias-child.json" showLineNumbers {9,15,21}
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "alias-child",
"type": "registry:item",
"files": [
{
"path": "registry/new-york/alias/target-alias-button.tsx",
"type": "registry:ui",
"target": "@ui/target-alias-button.tsx",
"content": "..."
},
{
"path": "registry/new-york/alias/target-alias-helper.ts",
"type": "registry:lib",
"target": "@lib/target-alias-helper.ts",
"content": "..."
},
{
"path": "registry/new-york/alias/prompt-input.tsx",
"type": "registry:ui",
"target": "@ui/ai/prompt-input.tsx",
"content": "..."
}
]
}
```
Registry dependencies can use target placeholders too. In the following example,
the child item installs a UI component and a helper, while the parent item
installs an app component and a hook.
```json title="alias-parent.json" showLineNumbers {7,13}
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "alias-parent",
"type": "registry:item",
"registryDependencies": ["https://example.com/r/alias-child.json"],
"files": [
{
"path": "registry/new-york/alias/target-alias-panel.tsx",
"type": "registry:component",
"target": "@components/target-alias-panel.tsx",
"content": "..."
},
{
"path": "registry/new-york/alias/use-target-alias.ts",
"type": "registry:hook",
"target": "@hooks/use-target-alias.ts",
"content": "..."
}
]
}
```
The `target` controls where the file is written, even when it differs from the
file `type`.
```json title="type-mismatch.json" showLineNumbers {9}
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "type-mismatch",
"type": "registry:item",
"files": [
{
"path": "registry/new-york/example/format-date.ts",
"type": "registry:ui",
"target": "@lib/format-date.ts",
"content": "..."
}
]
}
```
## registry:font
### Custom font

View File

@@ -9,7 +9,9 @@ If you're starting a new registry project, you can use the [registry template](h
## Requirements
You are free to design and host your custom registry as you see fit. The only requirement is that your registry items must be valid JSON files that conform to the [registry-item schema specification](/docs/registry/registry-item-json).
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).
Your registry can be a Next.js, Vite, Vue, Svelte, PHP or any other framework as long as it supports serving JSON over HTTP.
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.
@@ -19,11 +21,7 @@ The `registry.json` is the entry point for the registry. It contains the registr
Your registry must have this file (or JSON payload) present at the root of the registry endpoint. The registry endpoint is the URL where your registry is hosted.
The `shadcn` CLI will automatically generate this file for you when you run the `build` command.
## Add a registry.json file
Create a `registry.json` file in the root of your project. Your project can be a Next.js, Vite, Vue, Svelte, PHP or any other framework as long as it supports serving JSON over HTTP.
Here's an example `registry.json` file:
```json title="registry.json" showLineNumbers
{
@@ -31,44 +29,204 @@ Create a `registry.json` file in the root of your project. Your project can be a
"name": "acme",
"homepage": "https://acme.com",
"items": [
// ...
{
"name": "button",
"type": "registry:ui",
"title": "Button",
"description": "A simple button component.",
"files": [
{
"path": "components/ui/button.tsx",
"type": "registry:ui"
}
]
}
]
}
```
## Structure your registry
You can structure your source registry in one of two ways:
- Define all items in a single root `registry.json`.
- Use a root `registry.json` with `include` to compose multiple `registry.json` files.
### Option A: Single registry.json
Create a `registry.json` file in the root of your project. Add all your registry items to the `items` array. This is the simplest way to define a registry.
```json title="registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme",
"homepage": "https://acme.com",
"items": [
{
"name": "button",
"type": "registry:ui",
"title": "Button",
"description": "A simple button component.",
"files": [
{
"path": "components/ui/button.tsx",
"type": "registry:ui"
}
]
},
{
"name": "hello-world",
"type": "registry:block",
"title": "Hello World",
"description": "A simple hello world component.",
"registryDependencies": ["button"],
"files": [
{
"path": "registry/default/hello-world/hello-world.tsx",
"type": "registry:component"
}
]
}
]
}
```
This `registry.json` file must conform to the [registry schema specification](/docs/registry/registry-json).
## Add a registry item
### Option B: Using include
### Create your component
For larger registries, you can use `include` to compose your source registry
from multiple `registry.json` files.
Add your first component. Here's an example of a simple `<HelloWorld />` component:
```txt
registry.json
components
└── ui
├── button.tsx
├── input.tsx
└── registry.json
hooks
├── registry.json
├── use-media-query.ts
└── use-toggle.ts
```
```tsx title="registry/new-york/hello-world/hello-world.tsx" showLineNumbers
import { Button } from "@/components/ui/button"
The root `registry.json` defines the registry metadata and includes the nested
registry files.
export function HelloWorld() {
return <Button>Hello World</Button>
{/* prettier-ignore */}
```json title="registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme",
"homepage": "https://acme.com",
"include": [
"components/ui/registry.json",
"hooks/registry.json"
]
}
```
Included `registry.json` files are valid registry files for composition and may
omit `name` and `homepage`. Only the root `registry.json` must define the
registry metadata.
```json title="components/ui/registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"items": [
{
"name": "button",
"type": "registry:ui",
"files": [
{
"path": "button.tsx",
"type": "registry:ui"
}
]
},
{
"name": "input",
"type": "registry:ui",
"files": [
{
"path": "input.tsx",
"type": "registry:ui"
}
]
}
]
}
```
```json title="hooks/registry.json" showLineNumbers
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"items": [
{
"name": "use-toggle",
"type": "registry:hook",
"files": [
{
"path": "use-toggle.ts",
"type": "registry:hook"
}
]
},
{
"name": "use-media-query",
"type": "registry:hook",
"files": [
{
"path": "use-media-query.ts",
"type": "registry:hook"
}
]
}
]
}
```
When using `include`, file paths are relative to the `registry.json` file that
declares the item.
## Add an item
### Create a UI component
Add your first item. Here's an example of a simple `<Button />` component:
```tsx title="components/ui/button.tsx" showLineNumbers
import * as React from "react"
export function Button(props: React.ComponentProps<"button">) {
return (
<button
{...props}
className="rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white"
/>
)
}
```
<Callout className="mt-6">
**Note:** This example places the component in the `registry/new-york`
directory. You can place it anywhere in your project as long as you set the
correct path in the `registry.json` file and you follow the `registry/[NAME]`
directory structure.
**Note:** This example places the component in the `components/ui` directory.
You can place it anywhere in your project as long as you set the correct path
in the `registry.json` file.
</Callout>
```txt
registry
└── new-york
└── hello-world
└── hello-world.tsx
components
└── ui
└── button.tsx
```
### Add your component to the registry
### Add the item to the registry
To add your component to the registry, you need to add your component definition to `registry.json`.
To add your component to the registry, add an item definition to `registry.json`.
If you are using `include`, add the item to the included `registry.json` file
that owns the component. For example, add a UI component to
`components/ui/registry.json`.
```json title="registry.json" showLineNumbers {6-17}
{
@@ -77,14 +235,14 @@ To add your component to the registry, you need to add your component definition
"homepage": "https://acme.com",
"items": [
{
"name": "hello-world",
"type": "registry:block",
"title": "Hello World",
"description": "A simple hello world component.",
"name": "button",
"type": "registry:ui",
"title": "Button",
"description": "A simple button component.",
"files": [
{
"path": "registry/new-york/hello-world/hello-world.tsx",
"type": "registry:component"
"path": "components/ui/button.tsx",
"type": "registry:ui"
}
]
}
@@ -94,80 +252,393 @@ To add your component to the registry, you need to add your component definition
You define your registry item by adding a `name`, `type`, `title`, `description` and `files`.
For every file you add, you must specify the `path` and `type` of the file. The `path` is the relative path to the file from the root of your project. The `type` is the type of the file.
For every file you add, you must specify the `path` and `type` of the file. In a single-file registry, the `path` is relative to the root of your project. When using `include`, the `path` is relative to the `registry.json` file that declares the item. The `type` is the type of the file.
You can read more about the registry item schema and file types in the [registry item schema docs](/docs/registry/registry-item-json).
## Build your registry
## Serve your registry
### Install the shadcn CLI
You can serve your registry as static JSON files or from dynamic route handlers.
### Option A: Static JSON files
Run the build command to generate static registry JSON files.
```bash
npm install shadcn@latest
npx shadcn@latest build
```
### Add a build script
Add a `registry:build` script to your `package.json` file.
```json title="package.json" showLineNumbers
{
"scripts": {
"registry:build": "shadcn build"
}
}
```
### Run the build script
Run the build script to generate the registry JSON files.
```bash
npm run registry:build
```
If your source registry uses `include`, `shadcn build` resolves the included
registries and writes a flattened registry to your output directory. The
generated `registry.json` does not contain `include`.
<Callout className="mt-6">
**Note:** By default, the build script will generate the registry JSON files
in `public/r` e.g `public/r/hello-world.json`.
You can change the output directory by passing the `--output` option. See the [shadcn build command](/docs/cli#build) for more information.
**Note:** By default, the build command will generate the registry JSON files
in `public/r` e.g `public/r/button.json`. You can change the output directory by passing the `--output` option. See the [shadcn build command](/docs/cli#build) for more information.
</Callout>
## Serve your registry
If you're running your registry on Next.js, you can now serve your registry by running the `next` server. The command might differ for other frameworks.
If you're running your registry on Next.js, you can serve these files by running
the `next` server. The command might differ for other frameworks.
```bash
npm run dev
```
Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http://localhost:3000/r/hello-world.json`.
Your files will now be served at `http://localhost:3000/r/[NAME].json` eg. `http://localhost:3000/r/button.json`.
### Option B: Dynamic route handlers
If you want to serve registry JSON from your source `registry.json` at request
time, use the producer-side loader APIs from `shadcn/registry`.
Install `shadcn` as a runtime dependency:
```bash
npm install shadcn
```
Use `loadRegistry` to serve the registry catalog.
```ts title="app/r/registry.json/route.ts" showLineNumbers
import { loadRegistry } from "shadcn/registry"
export async function GET() {
try {
const registry = await loadRegistry()
return Response.json(registry)
} catch (error) {
console.error(error)
return Response.json({ error: "Failed to load registry." }, { status: 500 })
}
}
```
Use `loadRegistryItem` to serve individual registry items.
```ts title="app/r/[name].json/route.ts" showLineNumbers
import { loadRegistryItem, RegistryItemNotFoundError } from "shadcn/registry"
export async function GET(
_request: Request,
context: {
params: Promise<{
name: string
}>
}
) {
const { name } = await context.params
try {
const item = await loadRegistryItem(name)
return Response.json(item)
} catch (error) {
if (error instanceof RegistryItemNotFoundError) {
return Response.json(
{ error: `Registry item "${name}" was not found.` },
{ status: 404 }
)
}
console.error(error)
return Response.json(
{ error: "Failed to load registry item." },
{ status: 500 }
)
}
}
```
Both loaders resolve `include` before returning JSON, so route handlers can use
the same source `registry.json` structure without running `shadcn build`.
<Accordion type="single" collapsible>
<AccordionItem value="content-negotiation">
<AccordionTrigger>Content negotiation</AccordionTrigger>
<AccordionContent>
The `shadcn` CLI supports **HTTP Content Negotiation**. This allows you to host your registry at any endpoint — including the root of your domain — and serve different content depending on who is asking.
From a single URL, you can serve:
<ul>
<li>
<strong>HTML</strong> to browsers — a landing page, documentation, or
marketing site.
</li>
<li>
<strong>JSON</strong> to the <code>shadcn</code> CLI — an installable
registry item.
</li>
<li>
<strong>Markdown</strong> to AI agents and LLMs — a machine-readable version
of your content.
</li>
</ul>
The client signals its preference using the `Accept` request header, and your server decides what to return.
#### Request headers
When the CLI makes a request to a registry, it sends the following headers:
- **User-Agent**: `shadcn`
- **Accept**: `application/vnd.shadcn.v1+json, application/json;q=0.9`
#### Root hosting
By checking these headers on your server, you can route CLI traffic to an installable registry item while keeping browser traffic flowing to your documentation or homepage.
The examples below assume your built registry item is served at `/r/index.json`. Adjust the path to match your output.
In Next.js, express this as a rewrite in `next.config.ts`. This keeps the negotiation in the routing layer and avoids a Proxy function for this static rewrite:
```typescript title="next.config.ts" showLineNumbers
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
async rewrites() {
return {
beforeFiles: [
{
source: "/",
has: [
{
type: "header",
key: "accept",
value: "(.*)application/vnd\\.shadcn\\.v1\\+json(.*)",
},
],
destination: "/r/index.json",
},
{
source: "/",
has: [
{
type: "header",
key: "user-agent",
value: "shadcn",
},
],
destination: "/r/index.json",
},
],
}
},
async headers() {
return [
{
source: "/",
headers: [{ key: "Vary", value: "Accept, User-Agent" }],
},
]
},
}
export default nextConfig
```
Or, in an Express.js server:
```javascript title="server.js" showLineNumbers
app.get("/", (req, res) => {
res.vary("Accept")
res.vary("User-Agent")
// Check if the client prefers the shadcn vendor type.
if (req.accepts("application/vnd.shadcn.v1+json")) {
return res.json(registryData)
}
// Optional: Secondary check for the User-Agent.
if (req.get("User-Agent") === "shadcn") {
return res.json(registryData)
}
// Otherwise, serve your documentation or homepage.
res.send(htmlContent)
})
```
This enables:
<ul>
<li>
<strong>Branded Registry URLs</strong>:{" "}
<code>shadcn add https://ui.example.com</code>
</li>
<li>
<strong>Shorter URLs</strong>: Users type your domain root, not{" "}
<code>/r/</code> or <code>/registry/</code> sub-paths.
</li>
<li>
<strong>Easy Mnemonics</strong>: Easier for users to remember and share your
registry.
</li>
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
## Test your registry
After your registry is being served, test it with the same CLI commands that
other developers will use.
### Using URL
Use the catalog URL for commands that discover items, like `list` and `search`.
Use item URLs for commands that read or install a specific item, like `view` and
`add`.
#### List items
Start by confirming that the registry catalog can be discovered.
```bash
npx shadcn@latest list http://localhost:3000/r/registry.json
```
#### Search items
Search the registry by query.
```bash
npx shadcn@latest search http://localhost:3000/r/registry.json --query button
```
#### View an item
Then view one registry item by name.
```bash
npx shadcn@latest view http://localhost:3000/r/button.json
```
#### Add an item
To test the install flow, run `add` from a project where you want to install the
item.
```bash
npx shadcn@latest add http://localhost:3000/r/button.json
```
### Using namespace
#### Add the registry
You can also test your registry with a namespace. From a project with a
`components.json` file, add your registry URL template to the project.
```bash
npx shadcn@latest registry add @acme=http://localhost:3000/r/{name}.json
```
The `{name}` placeholder must resolve to an item JSON file. For example,
`@acme/button` resolves to `http://localhost:3000/r/button.json`. The catalog is
still served separately at `http://localhost:3000/r/registry.json`.
#### List items
Then list the items in your registry.
```bash
npx shadcn@latest list @acme
```
#### Search items
Search the registry by query.
```bash
npx shadcn@latest search @acme --query button
```
#### View an item
View one registry item by name.
```bash
npx shadcn@latest view @acme/button
```
#### Add an item
To test the install flow, run `add` from a project where you want to install the
item.
```bash
npx shadcn@latest add @acme/button
```
See the [Namespaced Registries](/docs/registry/namespace) docs for more
information.
## Publish your registry
To make your registry available to other developers, you can publish it by deploying your project to a public URL.
To make your registry available to other developers, publish your project to a
public URL. Once deployed, users can install items directly from item URLs, or
they can add your registry as a namespace in their project.
### Share namespace setup instructions
If you want users to install items with a namespace like `@acme/button`, tell
them to add your registry URL template to their project. The `{name}`
placeholder is replaced by the item name when the CLI resolves the registry
item.
The template must resolve to item JSON files. For example, `@acme/button`
resolves to `https://acme.com/r/button.json`. Your registry catalog should still
be served separately at `https://acme.com/r/registry.json`.
They can add the namespace with the CLI.
```bash
npx shadcn@latest registry add @acme=https://acme.com/r/{name}.json
```
Or they can add it manually under the `registries` field in their
`components.json` file.
```json title="components.json" showLineNumbers
{
"registries": {
"@acme": "https://acme.com/r/{name}.json"
}
}
```
Users can then consume items from your registry by namespace.
```bash
npx shadcn@latest add @acme/button
```
### Add your namespace to the registry index
If your registry is open source and publicly available, you can submit your
namespace to the official registry index. This lets users add your namespace by
name instead of pasting the full URL template.
See the [Registry Index](/docs/registry/registry-index) docs for the submission
requirements.
## Guidelines
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 `new-york` as an example. It can be anything you want as long as it's nested under the `registry` directory.
- The following properties are required for the block definition: `name`, `description`, `type` and `files`.
- 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`.
- 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 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/new-york/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.
## Install using the CLI
To install a registry item using the `shadcn` CLI, use the `add` command followed by the URL of the registry item.
```bash
npx shadcn@latest add http://localhost:3000/r/hello-world.json
```
See the [Namespaced
Registries](/docs/registry/namespace) docs for more information on
how to install registry items from a namespaced registry.

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