Open Code
Верхний слой кода компонента открыт для правки. Меняете сам компонент, а не оборачиваете его в обёртки и override-стили.
Формула из первых строк документации. shadcn/ui не ставится через npm — CLI кладёт исходники компонентов прямо в проект, и дальше это ваш код: правьте верхний слой, а не оборачивайте чужой чёрный ящик. Поверх — платформа дистрибуции кода: реестр, схема, пресеты.
Философия
Документация формулирует их явно. Главный сдвиг: компонент — это ваш исходник, а не зависимость с версией и чужим API поверх.
Верхний слой кода компонента открыт для правки. Меняете сам компонент, а не оборачиваете его в обёртки и override-стили.
Единый предсказуемый интерфейс у всех компонентов: выучил один — понимаешь остальные. Композиция вместо конфигурации.
Плоская файловая схема плюс CLI: компоненты раздаются между проектами как код, а не как пакеты. Любой может поднять свой реестр.
Из коробки — аккуратный минимализм: нейтральный zinc, тонкие бордеры, радиус 0.5rem. Компоненты согласованы между собой без настройки.
Открытый код читают не только люди: LLM видит компонент целиком и может его понять и доработать. С 2025 у проекта есть свой MCP-сервер.
Как устроено
Точка входа — components.json в корне проекта: стиль, базовый цвет, алиасы путей. Дальше CLI берёт компоненты из реестра — официального или любого своего — и кладёт исходники в components/ui/.
$ npx shadcn@latest init
✔ Preflight checks.
✔ Which color would you like to use as base color? › Zinc
✔ Writing components.json.
$ npx shadcn@latest add button card tabs dialog
✔ Checking registry.
✔ Created 4 files:
- components/ui/button.tsx
- components/ui/card.tsx
- components/ui/tabs.tsx
- components/ui/dialog.tsx
$ npx shadcn@latest add @acme/login-form
# namespace: компонент из стороннего реестра
{
"style": "new-york",
"rsc": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"ui": "@/components/ui"
}
}
Стек: React, Tailwind CSS (с февраля 2025 — v4) и headless-примитивы: с июля 2026 базой по умолчанию стал Base UI, Radix UI полностью поддерживается, React Aria — first-class. Работает в Next.js, Vite, Remix, Astro и других.
Реестр — открытый протокол. Компонент описывается плоским JSON по схеме; неймспейсы вида @acme/… подключают сторонние реестры, с июля 2026 реестр умеет серверный поиск с пагинацией. CLI 3.0 и MCP-сервер вышли в августе 2025, npx shadcn create — в декабре 2025, apply и preset — в апреле 2026.
Компоненты
Всё, что ниже, работает прямо на этой странице и написано чистым CSS с щепоткой ванильного JS — без React, без Tailwind, без единого запроса наружу. Вкладка Code у каждого образца показывает настоящий CSS этой страницы: принцип Open Code — дословно.
.btn {
display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 16px;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 2px); /* 0.5rem - 2px */
font-size: 14px; font-weight: 500; cursor: pointer;
}
.btn-default { background: var(--primary); color: var(--primary-foreground); }
.btn-secondary { background: var(--secondary); color: var(--secondary-foreground); }
.btn-destructive { background: var(--destructive); color: var(--destructive-foreground); }
.btn-outline { background: var(--background); border-color: var(--input); }
.btn-ghost { background: transparent; }
.btn-link { text-decoration: underline; text-underline-offset: 4px; }
.btn-sm { height: 32px; padding: 0 12px; }
.btn-lg { height: 42px; padding: 0 24px; }
.btn[disabled] { pointer-events: none; opacity: 0.5; }.badge {
display: inline-flex; align-items: center;
padding: 2px 10px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 12px; font-weight: 600;
}
.badge-default { background: var(--primary); }
.badge-secondary { background: var(--secondary); }
.badge-destructive { background: var(--destructive); }
.badge-outline { border-color: var(--border); }.switch {
width: 40px; height: 22px; border-radius: 999px;
background: var(--input); transition: background 0.18s;
}
.switch::after { /* бегунок */
content: ''; width: 18px; height: 18px; border-radius: 50%;
background: var(--background); transition: transform 0.18s;
}
.switch[aria-checked="true"] { background: var(--primary); }
.switch[aria-checked="true"]::after { transform: translateX(18px); }
.checkbox { width: 17px; height: 17px; border-radius: 4px;
border: 1px solid var(--input); color: transparent; }
.checkbox[aria-checked="true"] {
background: var(--primary); color: var(--primary-foreground); }
.skeleton { background: var(--muted);
animation: sk-pulse 1.6s ease-in-out infinite; }Укажите почту — пришлём ссылку для входа.
.card {
max-width: 350px;
border: 1px solid var(--border);
border-radius: var(--radius); /* фирменные 0.5rem */
background: var(--card);
box-shadow: 0 1px 2px rgba(9,9,11,0.05);
}
.label { font-size: 13.5px; font-weight: 500; }
.input {
height: 36px; padding: 0 12px;
border: 1px solid var(--input);
border-radius: calc(var(--radius) - 2px);
background: transparent;
}
.input:focus-visible {
border-color: var(--ring);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--ring) 25%, transparent);
}.tabs-list {
display: grid; grid-template-columns: 1fr 1fr;
padding: 3px;
border-radius: var(--radius);
background: var(--muted); /* «канавка» под триггеры */
}
.tabs-list [role="tab"] {
padding: 6px 12px; border: none;
border-radius: calc(var(--radius) - 3px);
color: var(--muted-foreground);
}
.tabs-list [role="tab"][aria-selected="true"] {
background: var(--background);
color: var(--foreground);
box-shadow: 0 1px 2px rgba(9,9,11,0.05);
}
/* переключение — 12 строк ванильного JS внизу страницы */dialog.dlg {
width: min(430px, calc(100vw - 32px));
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card); padding: 24px;
box-shadow: 0 4px 12px rgba(9,9,11,0.08);
}
dialog.dlg::backdrop {
background: rgba(9, 9, 11, 0.6); /* фирменный оверлей */
}
/* открытие — нативный showModal(), Escape работает сам */.textarea {
display: block; width: 100%; min-height: 76px; padding: 8px 12px;
background: var(--background); color: var(--foreground);
border: 1px solid var(--input); border-radius: calc(var(--radius) - 2px);
font-size: 14px; line-height: 1.5; resize: vertical;
}
.textarea:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.textarea[disabled] { opacity: .5; cursor: not-allowed; }.select {
appearance: none; height: 36px; padding: 0 34px 0 12px;
background: var(--background); border: 1px solid var(--input);
border-radius: calc(var(--radius) - 2px); font-size: 14px;
}
/* шеврон без картинки: две границы, повёрнутые на 45° */
.select-wrap::after {
content: ""; position: absolute; right: 12px; top: 50%;
width: 8px; height: 8px; margin-top: -6px;
border-right: 1.5px solid var(--muted-foreground);
border-bottom: 1.5px solid var(--muted-foreground);
transform: rotate(45deg);
}
/* нативный select, а не своя выпадашка: клавиатура, прокрутка и
мобильный колёсный выбор работают сами */.radio {
appearance: none; width: 16px; height: 16px;
border: 1px solid var(--input); border-radius: 50%;
display: inline-grid; place-content: center;
}
.radio::before {
content: ""; width: 8px; height: 8px; border-radius: 50%;
transform: scale(0); background: var(--primary);
}
.radio:checked { border-color: var(--primary); }
.radio:checked::before { transform: scale(1); }
/* fieldset + legend, а не div: группа радио обязана иметь имя */.slider { appearance: none; background: transparent; }
.slider::-webkit-slider-runnable-track {
height: 6px; border-radius: 999px; background: var(--secondary);
}
.slider::-webkit-slider-thumb {
appearance: none; width: 16px; height: 16px; margin-top: -5px;
border-radius: 50%; background: var(--background);
border: 2px solid var(--primary);
}
/* -moz-* дубли обязательны: у Firefox свои имена псевдоэлементов,
и один общий селектор для обоих движков невалиден целиком */.field { display: grid; gap: 6px; }
.field .desc { font-size: 12.5px; color: var(--muted-foreground); }
.field .err { font-size: 12.5px; color: var(--destructive); }
.field.invalid label { color: var(--destructive); }
.field.invalid .input { border-color: var(--destructive); }
<!-- ошибка СВЯЗАНА с полем, иначе скринридер её не назовёт -->
<input class="input" aria-invalid="true" aria-describedby="f2e">
<span class="err" id="f2e">Такой даты не существует</span>.otp { display: flex; gap: 6px; }
.otp input {
width: 40px; height: 44px; text-align: center;
font-size: 17px; font-weight: 500;
border: 1px solid var(--input);
border-radius: calc(var(--radius) - 2px);
}
/* inputmode="numeric" — цифровая клавиатура на мобильном;
aria-label на КАЖДОЙ ячейке, иначе шесть безымянных полей */.bc { display: flex; align-items: center; gap: 6px; font-size: 13.5px; }
.bc a { color: var(--muted-foreground); text-decoration: none; }
.bc [aria-current="page"] { color: var(--foreground); font-weight: 500; }
<!-- aria-current, а не только цвет: иначе скринридер не назовёт,
где мы находимся -->
<nav class="bc" aria-label="Хлебные крошки">…</nav>.pg button {
min-width: 34px; height: 34px;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 2px);
}
.pg button:hover { background: var(--accent); }
.pg button[aria-current="page"] {
border-color: var(--input); background: var(--background); font-weight: 500;
}
.pg button[disabled] { opacity: .45; pointer-events: none; }
/* «…» помечен aria-hidden: это украшение, читать его вслух нечего */Наведи или дай фокус с клавиатуры (Tab).
.mb-menu { display: none; position: absolute; }
/* два условия раскрытия, и второе обязательно:
.open ставит клик мышью, :focus-within — вход с клавиатуры.
Без :focus-within меню недоступно тем, кто не пользуется мышью. */
.mb-item.open .mb-menu,
.mb-item:focus-within .mb-menu { display: flex; }.navmenu-panel {
position: absolute; display: none;
grid-template-columns: 1fr 1fr; gap: 8px;
box-shadow: var(--shadow-md);
}
.navmenu > div.open .navmenu-panel,
.navmenu > div:focus-within .navmenu-panel { display: grid; }
/* панель — сетка ссылок с описанием, а не плоский список:
у shadcn это главное отличие Navigation Menu от Dropdown */Активный пункт помечен aria-current="page" и подложкой,
а не только цветом текста: на светлой теме цвет один читался бы плохо.
.sb { display: grid; grid-template-columns: 208px 1fr; }
.sb-aside { background: var(--muted); border-right: 1px solid var(--border); }
.sb-aside a[aria-current="page"] {
background: var(--background); font-weight: 500;
box-shadow: var(--shadow-sm);
}
/* активный пункт — подложка + вес, не только цвет:
цветовой контраст на светлой теме слишком слабый сигнал */.sheet {
position: fixed; inset: 0 0 0 auto; width: min(420px, 92vw);
border-left: 1px solid var(--border);
animation: sheet-in .18s ease-out;
}
@media (prefers-reduced-motion: reduce) { .sheet { animation: none } }
<!-- нативный dialog: ловушка фокуса, Escape, инертный фон
и top-layer — бесплатно, без единой строки JS -->
<dialog class="ov-dialog"><div class="sheet">…</div></dialog>.drawer {
position: fixed; inset: auto 0 0 0; max-height: 80vh;
border-radius: var(--radius) var(--radius) 0 0;
}
.drawer .grip {
width: 44px; height: 4px; border-radius: 999px;
background: var(--border); margin: 10px auto 0;
}
/* Sheet и Drawer у shadcn различаются не кодом, а назначением:
бок — для формы на большом экране, низ — для мобильного */.pop { position: absolute; display: none; box-shadow: var(--shadow-md); }
.pop-wrap.open .pop,
.pop-wrap:focus-within .pop { display: block; }
/* :focus-within обязателен: поповер, доступный только мышью,
для клавиатуры не существует */.tip { opacity: 0; pointer-events: none; transition: opacity .12s; }
.tip-wrap:hover .tip,
.tip-wrap:focus-within .tip { opacity: 1; }
<!-- role="tooltip" + aria-describedby на кнопке:
без связи подсказка для скринридера просто чужой текст -->.hc { position: absolute; display: none; gap: 12px; }
.hc-wrap:hover .hc,
.hc-wrap:focus-within .hc { display: flex; }
/* отличие от Tooltip: карточка несёт СОДЕРЖАНИЕ (аватар, факты),
поэтому она не role="tooltip" и не описывает кнопку */<!-- отличие от Dialog: у Alert Dialog НЕТ закрытия по клику вне и
нет крестика — только явный выбор. Поэтому оверлей не слушает
click, а кнопка подтверждения помечена destructive. -->
<dialog class="ov-dialog">
<div class="card">
<h4>Удалить метку «сегодня»?</h4>
<p>Метка снимется с 7 карточек. Необратимо.</p>
<button class="btn btn-outline">Отмена</button>
<button class="btn btn-destructive">Удалить</button>
</div>
</dialog>.ctx-menu { position: fixed; display: none; }
.ctx-menu.open { display: flex; }
// нативного CSS-крючка на contextmenu нет — только JS:
area.addEventListener('contextmenu', function (e) {
e.preventDefault();
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
menu.classList.add('open');
});
// закрытие: клик где угодно и EscapeТот же .mb-menu, что у Menubar: один набор правил на всё,
иначе два похожих меню начнут расходиться по мелочам.
/* переиспользуем правила Menubar целиком — Dropdown у shadcn
это то же меню, отличается только якорь (кнопка, не полоса) */
.mb-item.open .mb-menu,
.mb-item:focus-within .mb-menu { display: flex; }| Владелец | Контур | Открыто | Просрочено |
|---|---|---|---|
| Вологдин | ИИ-команда | 14 | 2 |
| Горбовский | ANB | 9 | 0 |
| Юрташкин | Архитектура | 21 | 3 |
| Петрова | ИБ | 7 | 1 |
| Итого | 51 | 6 |
/* Числа — tabular-nums, иначе колонка цифр дрожит при смене значений
и таблица выглядит сломанной, хотя вёрстка цела. */
.tbl .num { text-align: right; font-variant-numeric: tabular-nums; }
.tbl th, .tbl td { padding: 9px 10px; border-bottom: 1px solid var(--border); }
.tbl th { font-weight: 500; color: var(--muted-foreground); font-size: 12.5px; }
.tbl tbody tr:hover { background: var(--muted); }
<!-- разметка настоящей таблицы, а не сетка из div: scope, caption и
tfoot читает скринридер, и работает поиск по странице -->| Код | Владелец | Дней в работе | |
|---|---|---|---|
| WP-118 | Вологдин | 2 | |
| WP-104 | Юрташкин | 9 | |
| OP-031 | Петрова | 14 | |
| AN-007 | Горбовский | 23 |
/* Выбранная строка помечена не одним цветом: атрибут состояния несёт
смысл, подложка только показывает его глазу. */
.tbl tbody tr[aria-selected="true"] { background: var(--accent); }
.tbl th[aria-sort="ascending"]::after { content: " ↑"; }
.tbl th[aria-sort="descending"]::after { content: " ↓"; }
// сортировка: значение берём из data-атрибута, не из текста ячейки —
// «14» и «2» как строки сортируются неверно
rows.sort((a, b) => val(a) > val(b) ? dir : -dir);
// у shadcn Data Table — это Table + TanStack Table:
// компонент даёт вид, состояние сортировки и выбора живёт в хукеВысоты заданы в разметке: при выключенном JS остаётся картинка, а не пустая рамка.
.chart { display: flex; align-items: flex-end; gap: 10px; height: 130px; }
.chart .bar i { display: block; background: var(--primary); min-height: 2px; }
.chart .bar.muted i { background: var(--secondary); }
<div class="bar"><i style="height:82%"></i><span>Вт</span></div>
<!-- график — это картинка со смыслом: role="img" плюс aria-label
с цифрами, иначе для скринридера здесь пусто -->
/* у shadcn Chart — обёртка над Recharts: те же токены темы,
график наследует палитру, а не тащит свою */.prog { height: 8px; border-radius: 999px; background: var(--secondary); overflow: hidden; }
.prog > i { display: block; height: 100%; background: var(--primary); }
.prog.danger > i { background: var(--destructive); }
<!-- проценты продублированы подписью: цвет полосы не читается
скринридером, а красный не различает часть зрячих -->
<div class="prog" role="progressbar" aria-valuenow="92"
aria-valuemin="0" aria-valuemax="100"><i style="width:92%"></i></div>Инициалы — это и есть запасной вид аватара: картинка может не загрузиться, а буквы есть всегда. На витрине внешних картинок нет вовсе.
.ava-row .ava {
width: 34px; height: 34px; border-radius: 50%;
background: var(--secondary); display: grid; place-content: center;
border: 2px solid var(--card); /* обводка цветом карточки — стопка не слипается */
margin-left: -8px; /* нахлёст */
}
.ava-row .ava:first-child { margin-left: 0; }
/* у shadcn Avatar = Image + Fallback: пока картинка грузится или
если она не пришла, показываются инициалы *//* Сегодня и выбранное — РАЗНЫЕ состояния, и приёмы для них разные:
рамка против заливки. Одним оттенком их не различить. */
.cal-grid button[aria-current="date"] { border-color: var(--input); font-weight: 600; }
.cal-grid button[aria-selected="true"] { background: var(--primary); color: var(--primary-foreground); }
.cal-grid button[disabled] { opacity: .35; pointer-events: none; }
.cal-grid button { font-variant-numeric: tabular-nums; } /* сетка не дёргается */
/* дни — настоящие <button>: Tab и Enter работают без единой строки JS.
У shadcn Calendar — обёртка над react-day-picker */Отдельного компонента нет: у shadcn Date Picker — это рецепт из двух, Popover плюс Calendar. Открывается кликом и фокусом с клавиатуры.
/* тот же .pop, что у Popover — шире ровно под сетку календаря.
Второй набор правил развёл бы два одинаковых по смыслу слоя. */
.pop.pop-cal { width: 292px; padding: 12px; }
.pop-wrap.open .pop, .pop-wrap:focus-within .pop { display: block; }
<!-- в реестре Date Picker нет: документация показывает его как
композицию Popover + Calendar. Композиция вместо конфигурации -->Слот под медиа резервирует место заранее: содержимое ниже не прыгает, когда картинка догрузится.
.aspect { width: 100%; aspect-ratio: 16 / 9; }
/* компонент существует ради одной вещи — резерва места до загрузки.
Раньше это делали хаком с padding-top: 56.25%; нативное
aspect-ratio закрыло приём, а компонент остался как имя для него */Работает и сочетание ⌘K / Ctrl+K на этой странице. Поиск фильтрует по ходу набора; когда не нашлось — так и сказано, а не пустая коробка.
<!-- палитра — тот же нативный <dialog>, что и Dialog партии 3:
Escape, ловушка фокуса и инертный фон достаются даром -->
<dialog class="cmd-dialog">
<input role="combobox" aria-controls="cmd-list" aria-expanded="true">
<ul role="listbox"><li role="option" aria-selected="true">…</li></ul>
</dialog>
// подсветка ведётся aria-selected, а не классом: у списка с role="listbox"
// это и есть состояние, класс был бы вторым источником правды
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault(); dialog.showModal();
}
});Отдельного компонента снова нет — это Popover с Command внутри. Раскрывается кликом и фокусом.
/* раскрытие по фокусу — не украшение: без него компонента
для клавиатуры не существует вовсе */
.cbx.open .cbx-pop,
.cbx:focus-within .cbx-pop { display: block; }
/* выбранное помечено галочкой И фоном: один только оттенок
различает не каждый глаз */
.cbx-pop li[aria-selected="true"] { background: var(--accent); }
.cbx-pop li[aria-selected="false"] .tick { visibility: hidden; }/* пролистывание пальцем, колесом и стрелками работает БЕЗ единой
строки JS — это родной scroll-snap. Кнопки добавлены поверх. */
.car { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; }
.car .slide { flex: 0 0 132px; scroll-snap-align: start; }
// кнопки листают на ширину слайда — и уважают «уменьшить движение»
track.scrollBy({ left: step * dir,
behavior: reduceMotion ? 'auto' : 'smooth' });Разделитель берётся мышью и стрелками с клавиатуры: наведите фокус табуляцией и жмите ← →.
<!-- ручка, которую можно только тащить мышью, для клавиатуры
не существует. Поэтому это role="separator" c tabindex и
aria-valuenow — стрелки двигают его так же, как мышь -->
<div class="sep" role="separator" tabindex="0"
aria-valuenow="42" aria-valuemin="20" aria-valuemax="80"></div>
sep.addEventListener('keydown', e => {
const d = e.key === 'ArrowLeft' ? -2 : e.key === 'ArrowRight' ? 2 : 0;
if (d) { e.preventDefault(); set(pct + d); }
});Полоса своя, а прокрутка настоящая: колесо, тач, PageUp/PageDown и Home/End работают, потому что это overflow, а не нарисованная эмуляция.
.sa { overflow-y: auto; scrollbar-width: thin;
scrollbar-color: var(--border) transparent; }
.sa::-webkit-scrollbar { width: 8px; }
.sa::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; }
<!-- tabindex="0" + role="region": прокручиваемая область обязана
получать фокус, иначе с клавиатуры её содержимое недостижимо -->
/* у shadcn Scroll Area — примитив с нарисованной полосой;
здесь тот же вид достигнут родной прокруткой */Появляется в правом нижнем углу окна и уходит сам через 4 секунды; крестик закрывает раньше. Стопка живёт в role="status" — сообщение слышно, а не только видно.
<!-- контейнер объявлен ЗАРАНЕЕ и пустым: aria-live читает изменения
существующей области. Если создать её вместе с первым тостом,
первое сообщение не прозвучит -->
<div class="toaster" role="status" aria-live="polite"></div>
.toaster { position: fixed; pointer-events: none; } /* пустая стопка
не перехватывает клики страницы */
.toaster .toast { pointer-events: auto; }
// автозакрытие ставим на паузу при наведении: тост, исчезнувший
// под курсором, невозможно ни дочитать, ни нажатьОдин из списка
Сколько угодно сразу
/* нажатое помечено НЕ одной заливкой: ещё вес шрифта и тень.
Разницу в 5% серого различает не каждый глаз и не каждый экран */
.tg button[aria-pressed="true"] {
background: var(--background); font-weight: 600; box-shadow: var(--shadow-sm);
}
// single и multiple — один компонент, разное поведение:
// в single перед нажатием гасим соседей, в multiple не трогаем
if (group.dataset.mode === 'single')
siblings.forEach(b => b.setAttribute('aria-pressed', 'false'));Раскрытие, клавиатура и поиск по странице работают без JS вообще: это родные <details> с общим name, и соседние браузер закрывает сам.
<!-- общий name делает группу взаимоисключающей средствами браузера;
без него это просто несколько независимых Collapsible -->
<details name="acc1" open><summary>Что такое реестр</summary>…</details>
<details name="acc1"><summary>Почему не npm-пакет</summary>…</details>
/* маркер убираем и рисуем шеврон из двух границ — картинки не нужны */
.acc summary { list-style: none; }
.acc summary::-webkit-details-marker { display: none; }
.acc details[open] summary::after { transform: rotate(-135deg); }
/* Ctrl+F находит текст в закрытых секциях и раскрывает их —
это даёт браузер, самодельная гармошка на div так не умеет */Тот же <details>, что у Accordion, но одиночный и без name: у shadcn это и есть вся разница между двумя компонентами.
<!-- Collapsible и Accordion — один механизм. Разводит их ровно
одно: у Accordion общий name, и браузер закрывает соседей -->
<details>
<summary>Показать ещё три</summary>
…
</details>
/* первый элемент оставлен снаружи details: свёрнутым должен быть
хвост, а не весь список — иначе непонятно, что сворачивается */Реестр
Полный состав со страницы docs/components на 11.08.2026 — от классических примитивов до свежих чат-компонентов 2026 года: Message, Bubble, Questionnaire.
Так отмечены 46 компонентов, воспроизведённые чистым CSS в секции выше. Два показаны под нынешними именами реестра: Sonner — это Toast, Form — это Field. Не воспроизведены 18 — среди них чат-компоненты 2026 года и служебные вроде Direction и Typography.
Экосистема
Генератор тем на ui.shadcn.com/themes: базовый цвет, радиус, светлая и тёмная схемы — на выходе готовый блок CSS-переменных. Ровно те токены, на которых собрана и эта страница.
Готовые крупные блоки — дашборды, сайдбары, страницы логина, календари. Ставятся тем же CLI, с февраля 2026 собраны и для Radix, и для Base UI. Открытый каталог, куда контрибьютит сообщество.
Библиотека графиков поверх Recharts: area, bar, line, pie, radar, radial — в тех же токенах темы. Копируются как код, как и всё остальное.
март 2023
shadcn выкладывает набор компонентов на Radix UI + Tailwind как код, а не npm-пакет. Идея «copy, paste, own» расходится мгновенно; вскоре автор присоединяется к Vercel.
2024
Из набора компонентов проект становится экосистемой: генератор тем, крупные готовые блоки и графики на Recharts.
февраль — август 2025
Поддержка Tailwind v4, универсальные элементы реестра для кросс-фреймворковой дистрибуции, CLI 3.0 c неймспейсами и собственный MCP-сервер — компоненты теперь ставят и AI-агенты.
2026
RTL-поддержка, blocks для обеих баз, чат-компоненты, Toast с промисами, Questionnaire, серверный поиск реестров. В июле Base UI становится базой по умолчанию — Radix остаётся полностью поддерживаемым.