Если вы следите за новинками экосистемы JavaScript, то должны были слышать об интересной библиотеке пользовательского интерфейса (user interface, UI) под названием shadcn/ui
. Вместо того, чтобы распространяться в виде пакета npm, компоненты shadcn/ui
добавляются с помощью интерфейса командной строки (command line interface, CLI), который помещает исходный код компонентов непосредственно в ваш проект. Разработчик библиотеки указывает причину такого решения на официальном сайте shadcn/ui
.
"Почему код для копирования/вставки, а не библиотека?
Идея заключается в том, что вы полностью владеете и контролируете код. Это позволяет вам решать, как именно будут построены и оформлены ваши компоненты.
Начните с настроек по умолчанию, а затем кастомизируйте компоненты под свои нужды.
Используя пакет npm, можно наткнуться на один недостаток — стиль всегда связан с реализацией. Дизайн компонентов должен быть отделен от их реализации".
На самом деле, shadcn/ui
— это не просто очередная библиотека компонентов, а технология, позволяющая представить дизайн-систему в виде кода.
Цель этой статьи — изучить архитектуру и реализацию shadcn/ui
.
Если вы еще не использовали shadcn/ui
, я советую просмотреть ее документацию и немного поэкспериментировать с ней, чтобы извлечь из статьи максимальную пользу.
Предисловие
Любой UI можно разбить на набор простых многократно используемых компонентов и их композицию. Можно сказать, что любой компонент отличается собственным поведением и визуальным представлением определенного стиля.
Поведение
За исключением компонентов, имеющих исключительно презентационное значение, остальные компоненты должны знать о вариантах взаимодействия с пользователем и отвечать соответствующим образом. Основы, необходимые для такого поведения, встроены в нативные элементы браузера и доступны для использования. Но в современных UI необходимы компоненты с поведением, для которого недостаточно только нативных элементов браузера (вкладки, аккордеоны, кнопки выбора даты и т.д.). Это приводит к необходимости создания кастомных компонентов, которые выглядят и ведут себя так, как мы задумали.
Создание кастомных компонентов на поверхностном уровне обычно несложно реализовать с помощью современных UI-фреймворков. Но чаще всего в таких реализациях упускаются из виду некоторые очень важные аспекты поведения компонента. К примеру, состояние фокуса/снятия фокуса, навигация с помощью клавиатуры и следование стандарту WAI-ARIA (соблюдение принципов доступности). Несмотря на то, что поведение очень важно для обеспечения доступности UI, правильная реализация этого поведения в соответствии со спецификациями W3C — очень сложная задача и может значительно замедлить разработку продукта.
Учитывая быстро меняющуюся культуру разработки современного программного обеспечения (ПО), командам фронтенда трудно следовать рекомендациям по обеспечению доступности при создании кастомных компонентов. Одним из подходов к решению этой проблемы является разработка набора нестилизованных (unstyled) базовых компонентов, которые реализуют необходимое поведение, и использование их во всех проектах. Но у каждой команды должна быть возможность изменять и стилизовать эти компоненты без особых усилий, в соответствии с дизайном проекта.
Эти многократно используемые компоненты, которые не имеют стиля, но инкапсулируют функционал, известны как компоненты "безголового" (headless) UI. Они часто разрабатываются так, чтобы предоставлять API для управления их внутренним состоянием. Эта концепция является одним из основных архитектурных решений shadcn/ui
.
Стиль
Одна из основных особенностей компонентов UI — их визуальное представление. У всех компонентов по умолчанию есть стиль, основанный на общей визуальной теме проекта. Визуальные элементы компонента состоят из двух частей. Во-первых, это структурный аспект компонента. Сюда включаются такие свойства, как border-radius, расстояния, отступы, размеры и начертание шрифтов и т.д. Во-вторых, визуальный стиль. К этому аспекту относятся такие свойства, как цвета текста и фона, контур (outline), границы и т.п.
В зависимости от действий пользователя и состояния приложения, компонент может находиться в различных состояниях. Визуальный стиль компонента должен отражать его текущее состояние и
обеспечивать обратную связь при взаимодействии с пользователями. Для достижения этой цели необходимо создавать различные варианты одного и того же компонента. Они создаются путем изменения структуры и визуального стиля компонента для отражения его состояния.
В процессе разработки ПО команда дизайнеров определяет визуальную тему, компоненты и их варианты для создания детальных макетов приложения. Также дизайнеры отмечают предполагаемое поведение компонентов. Такой вид общей проектной документации для приложения называется дизайн-системой (design system).
Команда фронтенд-разработчиков должна воссоздать дизайн-систему в коде. Они должны зафиксировать основные переменные визуальной темы, повторно используемые компоненты и их варианты. Основное преимущество такого подхода заключается в том, что любые изменения, внесенные в дизайн-систему в будущем, могут быть полностью отражены в коде. Это облегчает взаимодействие между командами дизайнеров и разработчиков.
Обзор архитектуры
Как упоминалось ранее, shadcn/ui
— это технология, с помощью которой дизайн-системы могут быть выражены в коде. Она позволяет команде фронтенда перевести дизайн-систему в формат, который может быть использован в процессе разработки. На мой взгляд, архитектура, обеспечивающая такой рабочий процесс, достойна нашего обзора.
Дизайн всех компонентов shadcn/ui
можно схематично изобразить следующим образом.
В основе shadcn/ui
лежит один главный принцип — дизайн компонентов должен быть отделен от их реализации. Поэтому каждый компонент в shadcn/ui
имеет двухслойную архитектуру. А именно:
- слой структуры и поведения
- слой стилей
Слой структуры и поведения
На этом слое компоненты реализуются как безголовые. Как упоминалось в предисловии, это означает, что в этом представлении инкапсулируются структура и основной функционал компонента. На данном уровне реализуются даже такие сложные аспекты, как навигация с помощью клавиатуры и соблюдение WAI-ARIA.
shadcn/ui
использует некоторые хорошо зарекомендовавшие себя библиотеки безголового UI для компонентов, которые не могут быть реализованы с помощью нативных элементов браузера. Radix UI
— одна из таких библиотек, которую можно встретить в кодовой базе shadcn/ui
. Некоторые часто используемые компоненты, такие как аккордеон, модалки, табы и др. построены на основе их аналогов в Radix UI
.
Нативных элементов браузера и компонентов Radix UI
достаточно, чтобы закрыть большую часть требований к компонентам. Но в некоторых случаях приходится использовать специализированные библиотеки.
Один из таких случаев — работа с формами. Для этой цели shadcn/ui
предоставляет компонент Form
, построенный на основе React Hook Form, которая обрабатывает требования к управлению состоянием формы. shadcn/ui
берет примитивы, предоставляемые React Hook Form
, и модифицирует их за счет композиции.
Для работы с таблицами shadcn/ui
использует Tanstack React Table. Компоненты shadcn/ui
Table
и DataTable
построены на основе этой библиотеки. Tanstack React Table
предоставляет ряд API для работы с таблицами: фильтрацией, сортировкой и виртуализацией.
Календарь и элементы выбора DateTime и DateRange — одни из самых сложных с точки зрения правильной реализации компонентов. shadcn/ui
использует пакет React Day Picker в качестве основы для их реализации.
Слой стилей
В основе слоя стилей shadcn/ui
лежит TailwindCSS. Значения таких свойств, как color
, border-radius
и др. зависят от конфигурации Tailwind
и помещаются в файл global.css
как переменные CSS
. Так можно управлять значениями переменных, общими для всей дизайн-системы.
Для управления дифференцированной стилизацией вариантов компонентов в shadcn/ui
используется Class Variance Authority (CVA). Он содержит очень лаконичный API для настройки стилей вариантов каждого компонента.
После обсуждения высокоуровневой архитектуры shadcn/ui
, можно углубиться в детали реализации некоторых компонентов. Начнем с одного из самых простых компонентов.
Компоненты
Badge
Реализация компонента Badge
довольно проста. Это хорошая отправная точка для понимания того, как применяются концепции, упомянутые выше, для создания переиспользуемых компонентов.
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
Реализация компонента начинается с вызова функции cva()
из библиотеки class-variance-authority
. Она используется для определения вариантов компонента.
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
Первый аргумент функции cva()
определяет базовый стиль, который применяется ко всем вариантам компонента Badge
. В качестве второго аргумента cva()
принимает объект конфигурации, который определяет возможные варианты компонента и вариант, который должен использоваться по умолчанию. Также можно обратить внимание на служебные стили (utility styles), которые используются токенами дизайн-системы, установленными в tailwind.config.js
. Это позволяет легко обновлять внешний вид компонентов путем корректировки переменных CSS.
cva()
возвращает другую функцию, которая используется для применения стилей, соответствующих каждому варианту. Она сохраняется в переменной badgeVariants
, которая используется для применения подходящих стилей, когда название варианта передается компоненту в качестве пропа.
Интерфейс BadgeProps
определяет типы компонента.
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
Базовый элемент компонента badge
— это HTML-элемент div
. Следовательно, компонент должен быть представлен потребителям как расширение элемента div
. Сделать это можно путем расширения типа React.HTMLAttributes<HTMLDivElement>
. Кроме того, нам нужно извлечь проп variant
из компонента Badge
, чтобы предоставить потребителям возможность рендерить необходимый вариант компонента. Вспомогательный тип VariantProps
позволяет отображать доступные варианты в виде перечисления пропа variant
.
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
Наконец, у нас имеется функциональный компонент, определяющий Badge
. Отметим, что все пропы, кроме className
и variant
, объединяются в объект props
, который распаковывается на базовом div
. Это позволяет потребителям компонента передавать пропы, доступные элементу div
.
Обратите внимание как применяются стили. Значение пропа variant
передается в функцию badgeVariants()
, которая возвращает строку class
, содержащую все названия вспомогательных классов, необходимых для отображения варианта компонента. Однако можно заметить, что мы передаем возвращаемое значение указанной функции и значения, переданные в проп className
, через функцию cn()
перед ее передачей в атрибут className
элемента div
.
Это специальная вспомогательная функция, предоставляемая shadcn/ui
. Она выступает средством управления вспомогательными классами. Рассмотрим ее реализацию.
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Эта вспомогательная функция представляет собой объединение двух библиотек, которые помогают управлять вспомогательными классами. Первая библиотека — clsx. Она дает возможность условного применения стилей через объединение className
, применяемых к компоненту.
import React from "react";
const Link = ({ isActive, children }: { isActive: boolean, children: React.ReactNode }) => {
return <a className={clsx("text-lg", { "text-blue-500": isActive })}>{children}</a>;
};
Вот пример, когда clsx
используется независимо. По умолчанию к компоненту Link
будет применен только вспомогательный класс text-lg
. Но когда в проп isActive
передается значение true
, к компоненту применяется вспомогательный класс text-blue-500
.
Но бывают ситуации, когда одного clsx
оказывается недостаточно.
import React from "react";
import clsx from "clsx";
const Link = ({ isActive, children }: { isActive: boolean, children: React.ReactNode }) => {
return <a className={clsx("text-lg text-grey-800", { "text-blue-500": isActive })}> {children}</a>;
};
В этом случае к элементу по умолчанию применяется утилита цвета text-grey-800
. Наша цель — изменить цвет текста на blue-500
, когда isActive
имеет значение true
. Но из-за каскадности Tailwind
(CSS), цветовой стиль, примененный text-grey-800
, не будет изменен.
Здесь нам пригодится библиотека tailwind-merge.
import React from "react";
import { twMerge } from "tailwind-merge";
import clsx from "clsx";
const Link = ({ isActive, children }: { isActive: boolean, children: React.ReactNode }) => {
return <a className={twMerge(clsx("text-lg text-grey-800", { "text-blue-500": isActive }))}>{children}</a>;
};
Теперь результат clsx()
будет пропущен через tailwind-merge
. twMerge()
разберет строку класса и объединит определения стиля. То есть, text-grey-800
будет заменен на text-blue-500
, чтобы элемент отражал стили, соответствующие определенным условиям.
Такой подход гарантирует, что в наших вариантах реализации не возникнет конфликтов стилей. Поскольку проп className
также проходит через cn()
, можно легко переопределить любые стили, при необходимости. Однако, не все так просто. Использование cn()
дает возможность потребителю компонента переопределять стили случайным образом. Поэтому на этапе проверки кода необходимо проверять также правильное применение cn()
. Если нам не требуется такое поведение, код компонента можно модифицировать таким образом, чтобы в нем использовался только clsx()
.
Рассмотрев реализацию компонента Badge
, мы можем выделить некоторые закономерности, в том числе связанные с SOLID
:
- Принцип единственной ответственности (single responsibility principle, SRP):
- компонент
Badge
имеет одну ответственность — отображение значка с различными стилями в зависимости от указанного варианта. Он делегирует управление стилями объектуbadgeVariants
- компонент
- Принцип открытости/закрытости (open-closed principle, OCP):
- код, по-видимому, следует принципу открытости/закрытости, позволяя добавлять новые варианты без изменения существующего кода. Новые варианты компонента можно легко добавить с помощью объекта
variants
в определенииbadgeVariants
- но есть нюанс: из-за того, как используется
cn()
, потребитель компонента может передать новые переопределяющие стили с помощью атрибутаclassName
. Это может открыть компонент для модификации. Поэтому при создании собственной библиотеки компонентов с помощьюshadcn/ui
необходимо решить вопрос приемлемости такого поведение
- код, по-видимому, следует принципу открытости/закрытости, позволяя добавлять новые варианты без изменения существующего кода. Новые варианты компонента можно легко добавить с помощью объекта
- Принцип инверсии зависимостей (dependency inversion principle, DIP):
- компонент
Badge
и его стиль определяются отдельно. КомпонентBadge
опирается наbadgeVariants
для получения информации о стиле. Такое разделение обеспечивает гибкий подход и упрощает обслуживание, что соответствует принципу инверсии зависимостей
- компонент
- Согласованность и возможность повторного использования (consistency and reusability):
- согласованность обеспечивается за счет использования вспомогательной функции
cva()
для управления стилями и их применения на основе вариантов. Такая согласованность может облегчить разработчикам понимание и использование компонента.Badge
— многократно используемый компонент, который может быть легко интегрирован в различные части приложения
- согласованность обеспечивается за счет использования вспомогательной функции
- Разделение задач (separation of concerns):
- задачи стилизации и визуализации разделены. Объект
badgeVariants
управляет стилизацией, а компонентBadge
отвечает за визуализацию и применение стилей
- задачи стилизации и визуализации разделены. Объект
Проанализировав реализацию компонента Badge
, мы получили детальное представление об общей архитектуре shadcn/ui
. Но Badge
— это компонент уровня отображения (статический). Рассмотрим какой-нибудь интерактивный компонент.
Switch
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
Перед нами компонент Switch
, часто использующийся в современных UI для переключения определенного поля между двумя значениями. В отличие от компонента Badge
, который является статическим (отвечает только за отображение), Switch
— интерактивный компонент, реагирующий на действия пользователя путем переключения состояния. Он также информирует пользователя о текущем состоянии при помощи визуального стиля.
Основной способ, которым пользователь может взаимодействовать с компонентом Switch
, — это клик/тап (нажатие) на переключатель. Создание компонента Switch
, реагирующего на события указателя (pointer events), довольно простое. Однако его реализация значительно усложняется, когда необходимо, чтобы переключатель реагировал на клавиатуру, а также на устройства чтения с экрана (screen readers). От компонента Switch
можно ожидать следующего поведения:
- Реагирует на нажатие клавиши
Tab
, позволяя сфокусироваться на переключателе. - После фокусировки, нажатие клавиши
Enter
переключает его состояние. - При использовании скринридера, он должен иметь доступ к информации о текущем состоянии переключателя.
Если внимательно проанализировать код, можно заметить, что фактическая структура компонента создается с помощью составных компонентов <SwitchPrimitives.Root/>
и <SwitchPrimitives.Thumb/>
. Эти компоненты импортируются из Radix UI
и содержат всю необходимую реализацию ожидаемого поведения переключателя. Также можно отметить, что для создания этого компонента используется React.forwardRef()
. Это позволяет привязать компонент к входящим ref
, а это удобная функция, когда необходимо отследить состояние фокусировки и выполнить интеграцию с внешними библиотеками (например, чтобы использовать компонент в качестве инпута библиотеки React Hook Form
, он должен быть фокусируемым через ref
).
Как отмечалось ранее, компоненты Radix UI
не предоставляют никаких стилей. Поэтому стили были применены к этому компоненту через проп className
непосредственно после прохождения через вспомогательную функцию cn()
. При необходимости можно создать варианты переключателя с помощью cva()
.
Заключение
Архитектура и анатомия shadcn/ui
, о которых мы говорили, реализованы и в остальных компонентах shadcn/ui
. Однако поведение и реализация некоторых компонентов немного сложнее. Рассмотрение архитектуры этих компонентов требует отдельной статьи. Поэтому я не буду вдаваться в подробности, а просто дам общее представление об их структуре.
- Calendar:
- в качестве базового компонента используется
react-day-picker
- в качестве библиотеки форматирования даты и времени используется date-fns
- в качестве базового компонента используется
- Table и DataTable:
- в качестве безголовой библиотеки используется
@tanstack/react-table
.
- в качестве безголовой библиотеки используется
- Form:
- в качестве безголовой библиотеки используется
react-hook-form
- вспомогательные компоненты, инкапсулирующие логику формы, предоставляются
shadcn/ui
. Они могут быть использованы для сборки частей формы, включая поля для ввода данных и сообщения об ошибках - zod используется для валидации формы (проверки ее схемы). Ошибки, возвращаемые
zod
, передаются в компонентыFormMessage
, которые отображают ошибки рядом с вводимыми данными
- в качестве безголовой библиотеки используется
shadcn/ui
ввел новую парадигму в представление о фронтенд-разработке. Вместо того, чтобы использовать сторонние библиотеки, абстрагирующие весь компонент, вы можете сами реализовывать компоненты и предоставлять доступ только к необходимым элементам. Вместо того, чтобы ограничиваться заурядным API готовой библиотеки компонентов при применении дизайн-системы, создайте свою собственную дизайн-систему с удобными настройками по умолчанию, которые можно легко модифицировать в будущем.