Headless-підхід до роботи з компонентами shadcn/ui. Колекція reusable компонентів

Усі статті, обговорення, новини про Front-end — в одному місці. Підписуйтеся на телеграм-канал!

Вітаю! Мене звати Захар Шульга, я Front-end розробник у компанії TRIARE.

Якось до нас в компанію зайшов новий проєкт. Треба було щось гнучке, швидке й легке в розробці, та функціональне. Після недовгих пошуків наткнувся на shadcn/ui, зацікавився, та зрозумів — це воно! А ще і впевнений, що знаннями та досвідом варто ділитись.

Тому сьогодні розповім про shadcn/ui, її підходи до роботи з компонентами, та що воно взагалі таке.

Кому буде корисною ця стаття?

  • Front-end розробникам, які шукають готові React-компоненти для створення інтерфейсів користувача (підтримується будь-який React-based фреймворк, Next.js, Astro, Remix, Gatsby тощо);
  • командам, які прагнуть стандартизувати дизайн інтерфейсу;
  • розробникам, які хочуть створювати доступні та інклюзивні інтерфейси;
  • тим, хто цікавиться Tailwind CSS та його застосуванням у React-проєктах.

So, let’s get started! 😎

Передмова

Під час розробки сучасних вебзастосунків командам практично завжди треба використовувати кастомні компоненти, які розширюють список базових елементів браузера (таби, акордіони, дейт-пікери тощо).

З допомогою сучасних UI-фреймворків створення кастомних компонентів на поверхневому рівні не складає ніяких проблем. Але трапляється таке, що саме у вашому проєкті в елемента має бути, наприклад, додатковий івент, коли користувач натискає кнопку на клавіатурі.

У такому випадку вам доведеться створювати свій компонент чи якусь обгортку, або ж писати «костилі». І це не призведе ні до чого хорошого. Витратиться більше часу, ніж було заплановано, та ламаються принципи доступності контенту WAI-ARIA.

Одне з розв’язань цієї проблеми — розробка unstyled-компонентів, які несуть в собі необхідну поведінку. Також у кожної команди має бути можливість змінити та стилізувати ці компоненти без великих зусиль. Такі компоненти відомі ще як headless UI. Вони розробляються так, щоб надавати API для керування їх внутрішнім станом.

Архітектура shadcn/ui

Тепер трохи більше про архітектуру. Замість npm-пакета, shadcn/ui використовує CLI для гнучкого додавання компонентів, що дає розробникам більше контролю та можливостей. Саме тому вони можуть змінювати абсолютно кожен аспект поведінки компонента.

Також є альтернативний спосіб додати компонент: просто скопіювати та вставити код у свій проєкт (наприклад в src/components/UI).

shadcn/ui — Це НЕ бібліотека компонентів. Це колекція reusable компонентів, які ви можете копіювати та вставляти у свої програми.

Чому ж? Розробник shadcn/ui відповідає:

Ідея полягає в тому, що ви повністю володієте кодом та контролюєте його. Це дозволяє вирішувати, як саме будуть побудовані та оформлені ваші компоненти. Почніть за замовчуванням, а потім кастомізуйте компоненти під свої потреби.

Використовуючи пакет npm, можна натрапити на один недолік — стиль завжди пов’язаний із реалізацією. Дизайн компонентів має бути відокремлений від їх реалізації.

shadcn/ui допомагає команді Front-end легко визначити дизайн-систему в коді. А все через те, що один з головних принципів shadcn такий: дизайн компонентів повинен бути відокремлений від реалізації. Тому кожен з компонентів поділяється на шари:

  • структури та поведінки;
  • стилів.

Шар структури та поведінки

В цьому шарі компоненти реалізуються як безголові. Сюди належить структура, основний функціонал, складні аспекти (наприклад, хендлінг клавіатури) та a11y.

Для компонентів, у яких немає нативних аналогів в браузері, shadcn/ui в основному використовує бібліотеки headless-компонентів. Наприклад, Radix UI використовується в таких компонентах як таби, селекти, модалки, акордіони, тултіпи тощо.

Нативних елементів та компонентів Radix UI загалом достатньо, щоб закрити більшість потреб до компонентів. Але в деяких випадках, щоб не вигадувати велосипед, доводиться використовувати спеціалізовані бібліотеки.

Один з таких прикладів — це робота з формами. Для побудови форм shadcn/ui використовує react-hook-form. Бере примітиви, які надає бібліотека, та модифікує їх через композицію. На виході ми отримуємо компонент <Form>.

Для роботи з таблицями використовується tanstack-react-table. Колекція має відразу 2 компоненти, побудовані на базі цієї бібліотеки.

Один спрощений, Table, який будується зі звичайних компонентів, TableHeader, TableRow, TableBody. А другий — розширений, він використовує елементи з Table, та за допомогою хуків tanstack будує таблицю з фільтрацією, сортуванням, expandable, чи будь-чим, що спаде на думку, бо, нагадую: код — ваш. Можете модифікувати компоненти як вам заманеться.

Календар, DatePicker / DateRange — одні з найскладніших з точки реалізації компонентів. shadcn/ui використовує react-day-picker як основу для реалізації.

Шар стилів

Tailwind CSS є основою шару стилів в shadcn/ui. Змінні CSS (color, border-radius тощо) поміщаються у файл global.css.

@tailwind base;
@tailwind components;
@tailwind utilities;


@layer base {
 :root {
   --primary: hsla(202, 44%, 28%, 1);
   --border: #FBFBFD;


   --radius: 6px;

Після чого підключаються в tailwind.config.js.

module.exports = {
 …
 theme: {
   extend: {
     colors: {
       primary: {
	    DEFAULT: 'var(--primary)',
	    foreground: 'var(--primary-foreground)',
  },
       border: 'var(--border)',
	  …
     },
     borderRadius: {
       lg: 'calc(var(--radius) + 2px)',
       md: 'var(--radius)',
       sm: 'calc(var(--radius) - 2px)',
	  …
     },
	…
   },
  …
 },
};

Для застосування та поєднання класів рекомендують використовувати допоміжну функцію, яка містить tailwind-merge & clsx (lib/utils.ts).

import { ClassValue, clsx } from ’clsx’;<br>import { twMerge } from ’tailwind-merge’;

<p>export function cn(...inputs: ClassValue[]) {<br>return twMerge(clsx(inputs));<br>}<br></p>

Це потрібно через специфіку Tailwind, який являється каскадним, що не дозволяє перезаписувати уже застосований клас. Наприклад:

<p className={clsx('text-primary', { 'text-secondary': secondary })}
>Text</p>

В цьому випадку «text-primary» не буде перезаписано, тому тут на допомогу приходить утиліта «twmerge», яка застосовує останній доданий клас до елемента.

Робочий варіант виглядатиме так:

<p className={twMerge(clsx('text-primary', { 'text-secondary': secondary }))}
>Text</p>

Використовуючи цей підхід, ми гарантовано уникнемо конфліктів в наших варіантах стилізації. Тому створюємо утиліту cn — та використовуємо)

Стилізація безпосередньо варіантів компонентів реалізується за допомогою Class Variance Authority (cva). Ця бібліотека надає простий API для їх налаштування.

Компоненти

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

Першим аргументом функція приймає дефолтні стилі, які застосовуються до всіх варіантів компонента. Другим аргументом — об’єкт конфігурації, в якому ми визначаємо variants, defaultVariants та можемо додати інші опціональні пропси.

Ця функція повертає іншу функцію, яка використовується для застосування стилів залежно від вказаного варіанту. Інтерфейс для даного компонента вказується за допомогою VariantProps, яка динамічно генерує всі можливі пропси.

export interface BadgeProps
 extends React.HTMLAttributes<HTMLDivElement>,
   VariantProps<typeof badgeVariants> {}

Компонент Badge побудований на базі елемента div, тому ми розширюємо інтерфейс компонента за допомогою React.HTMLAttributes<HTMLDivElement> задля того, щоб надати можливість модифікувати всі пропси цього елемента. Також нам потрібно витягнути variant компонента з пропсів, щоб прокинути його в нашу функцію badgeVariants.

function Badge({ className, variant, ...props }: BadgeProps) {
 return (
   <div className={cn(badgeVariants({ variant }), className)} {...props} />
 )
}

Тут ми й використовуємо вище описану функцію cn(), яка отримує результат виклику badgeVariants({ variant }) та className, після чого об’єднує стилі.

Всі інші пропси ми передаємо в div за допомогою spread оператора.

Також варто зазначити, що у cva можна використовувати як TW стилі, так і scss-модулі за потреби.

const badgeVariants = cva(
 [styles.defaultBadge, 'inline-flex items-center…']
 ...)

Після дослідження структури цього компонента ми можемо отримати детальне уявлення про загальну архітектуру компонентів shadcn/ui, а також побачити зв’язок з деякими принципами SOLID.

  • Розділення завдань (Separation of concerns). Завдання стилізації та візуалізації розділені. Об’єкт badgeVariants керує стилізацією, а компонент Badge відповідає за візуалізацію та застосування стилів.
  • Single Responsibility Principle (SRP). Компонент Badge відповідає лише за одне завдання — відображення значка. Він делегує керування стилями об’єкта badgeVariants.
  • Open-Closed Principle (OCP). Компонент дозволяє додавати нові варіанти без змін в наявному коді, просто модифікуючи об’єкт variants. Але через те, як використовується cn(), є можливість передати в компонент нові стилі за допомогою className. Це може відкрити компонент для модифікації.
  • Dependency Inversion Principle (DIP). Компонент та його стиль визначаються окремо. Для отримання стилів Badge спирається на badgeVariants, що додає гнучкості та спрощує обслуговування, що відповідає принципу інверсії залежностей.
  • Узгодженість і можливість повторного використання. Завдяки використанню допоміжної функції cva для управління стилями забезпечується узгодженість. Це може полегшити розробникам розуміння та використання компонента. Також через використання cva ми можемо створювати безліч варіантів компонента, внаслідок чого він може бути легко інтегрований у різні частини застосунку.

Accordion

Перейдемо до реалізації інтерактивних компонентів, а саме Accordion. При повноцінній реалізації цей компонент має реагувати не тільки на кліки користувача, а й на івенти клавіатури чи скрин-рідера.

Наприклад, при натисканні на Tab він має фокусуватись. Після фокусування при натисканні на Enter має перемикатись стан вибраної панелі (Panel), а при використанні скрин-рідера — надаватись інформація про її стан.

import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { cn } from "@/lib/utils"


const Accordion = AccordionPrimitive.Root;


const AccordionItem = React.forwardRef<
 React.ElementRef<typeof AccordionPrimitive.Item>,
 React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
 <AccordionPrimitive.Item
   ref={ref}
   className={cn('border-b', className)}
   {...props}
 />
));


AccordionItem.displayName = 'AccordionItem';


const AccordionTrigger = React.forwardRef<
 React.ElementRef<typeof AccordionPrimitive.Trigger>,
 React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
 <AccordionPrimitive.Header className="flex">
   <AccordionPrimitive.Trigger
     ref={ref}
     className={cn(
       'flex flex-1 items-center justify-between py-4 font-medium transition-all '
       + '[&[data-state=open]>svg]:rotate-180',
       className,
     )}
     {...props}
   >
     {children}
   </AccordionPrimitive.Trigger>
 </AccordionPrimitive.Header>
));


AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;


const AccordionContent = React.forwardRef<
 React.ElementRef<typeof AccordionPrimitive.Content>,
 React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({
 className,
 children,
 style,
 ...props
}, ref) => (
 <AccordionPrimitive.Content
   ref={ref}
   className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up
   data-[state=open]:animate-accordion-down"
   {...props}
 >
   <div className={cn('pb-4 pt-0', className)} style={style}>{children}</div>
 </AccordionPrimitive.Content>
));


AccordionContent.displayName = AccordionPrimitive.Content.displayName;


export {
 Accordion, AccordionItem, AccordionTrigger, AccordionContent,
};

Даний компонент базується на Accordion з radix-ui та використовує складові компоненти з цієї бібліотеки, а саме: <AccordionPrimitive.Item/>, <...Header/>, <...Trigger/> та <...Content/>.

radix-ui реалізовує всю необхідну поведінку та підтримку різних девайсів, а shadcn/ui, своєю чергою, додає стилі через className, перед цим прокидаючи все через утиліту cn(). В даному компоненті не реалізовано різних варіантів по дефолту, але за потреби це можна легко зробити, використовуючи cva.

Також варто зазначити, що компонент використовує функцію forwardRef(). Це дозволяє прив’язати ref до компонента, який потрібен для відстежування різних івентів компонента (наприклад, стан фокусування), для викликання якихось функцій, чи для виконання інтеграцій з зовнішніми бібліотеками.

Хоч forwardRef() і буде deprecated в React-19, про це не варто турбуватись, бо розробник shadcn/ui вже повідомив, що підготує оновлення відразу слідом за radix-ui 😉

Button

І ще один інтерактивний компонент, де ми розглянемо розширені налаштування в CVA, а саме Button.

import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"




const buttonVariants = cva(
 ['inline-flex items-center justify-center',
   'whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors',
   'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
   'disabled:pointer-events-none disabled:opacity-50'],
 {
   variants: {
     variant: {
       default: 'bg-primary text-primary-foreground hover:bg-primary/90',
       dark: 'bg-primary-dark text-white hover:opacity-85',
       destructive:
         'bg-destructive text-destructive-foreground hover:bg-destructive/90',
       outline:
         'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
       secondary:
         'bg-secondary text-secondary-foreground hover:bg-secondary/80 disabled:opacity-60',
       ghost: 'hover:bg-accent hover:text-accent-foreground',
       link: 'text-primary underline-offset-4 hover:underline',
     },
     size: {
       default: 'h-[44px] px-[30px] py-[9px]',
       sm: 'h-[33px] rounded-lg p-[10px] !text-sm',
       lg: 'h-10 rounded-md px-8',
       icon: 'h-[36px] w-[36px] [&_svg_path]:fill-white',
       auto: 'h-auto',
     },
   },
   defaultVariants: {
     variant: 'default',
     size: 'default',
   },
 },
);




export interface ButtonProps
 extends React.ButtonHTMLAttributes<HTMLButtonElement>,
   VariantProps<typeof buttonVariants> {
 asChild?: boolean;
}


const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
 (
   {
     className,
     variant,
     size,
     asChild = false,
     ...props
   },
   ref,
 ) => {
   const Comp = asChild ? Slot : 'button';


   return (
     <Comp
       className={cn(buttonVariants({ variant, size, className }))}
       ref={ref}
       {...props}
     />
   );
 },
);


Button.displayName = 'Button';


export { Button, buttonVariants };

Цей компонент включає одразу два параметри в variants, які впливають на його вигляд. До звичного variant я додав size та тепер можна використовувати один компонент для різноманітних завдань. Наприклад, не тільки для звичайних кнопок, а і для кнопок, вбудованих в текст, використовуючи size: auto. Або для іконок, використовуючи size: icon.

Також в цьому компоненті є цікава реалізація asChild за допомогою компонента Slot з radix-ui. Якщо передати цей проп, то кнопка передасть всі свої властивості до children компонента. Це може бути корисно, коли потрібно додати елемент, що має специфічний вигляд, але водночас діє як кнопка.

<DrawerClose asChild {...props}>
 <CloseIcon className="size-[28px]" />
</DrawerClose>

Структуру деяких інших компонентів розберемо коротко:

  • Table & DataTable. Як я раніше вже говорив, для безголової реалізації таблиці використовується tanstack-react-table, а компонент DataTable будується на базі Table.
  • Form. Роль безголової бібліотеки виконує react-hook-form. Компоненти, які хендлять логіку форми, реалізовані shadcn/ui, а для валідації використовується бібліотека zod.

Помилки, які знаходяться при перевірці схеми, відображаються в компоненті FormMessage.

  • Calendar. Для реалізації компонента використовується бібліотека react-day-picker, а для форматування дати та часу використовується date-fns.

Висновок

З появою shadcn/ui розробникам більше не треба використовувати сторонні бібліотеки, які повністю реалізують компоненти. Щоб не обмежуватись стандартним API цих бібліотек, ви можете створювати власні компоненти, надаючи доступ до потрібних аспектів.

Рекомендую почати з розгляду документації tailwind.css, оскільки візуалізація компонентів базується на ньому. А після почати додавати найпростіші компоненти, розширюючи їх функціонал за потреби.

Повірте, я на власному досвіді переконався: коли вам потрібне гнучке рішення, простіше використати shadcn/ui, аніж писати компоненти з нуля. Це менш енергозатратно і вам не доведеться турбуватися про доступність контенту (a11y).

У мене був випадок, коли довелось розшити функціонал таблиці та додати в неї розгортання рядків. Хоч в стандартній реалізації shadcn/ui цього немає — це не викликало ніяких проблем. Спокійно використав розширення з tanstack, додав один state, Accordion-компонент (з цієї ж shadcn) — і все готово!

Отже, за допомогою такого підходу ви можете створити свою власну бібліотеку компонентів, яка буде повністю відповідати вашим вимогам. Та потім використовувати її для різних проєктів.

👍ПодобаєтьсяСподобалось14
До обраногоВ обраному6
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Подобається ця концепція та свобода. Вже рік у себе юзаємо шадан

Дякую за статтю, радий що шириться комьюніті. Чи була у проєкті необхідність розширити бібліотеку своїми компонентами, крім випадків, коли видозмінювали стандартний компонент?

Дякую за відгук!)
Була необхідність в Rich-Text-Editor (редактор html), створювали за допомогою react-quill використовуючи підхід з cva та різними варіантами). А так всі інші модифікації це були просто видозміни компонентів shadcn/ui під певну задачу. 😁

Загалом все виглядає досить непогано, і, безумовно, у ситуаціях, коли потрібно більше можливостей, shadcn може бути корисною. Проте, функція

cn

виглядає як тимчасове рішення. Чи планується її вдосконалення в майбутньому?

Дякую за відгук, Олег!
Зараз інформації про це нема, але я гадаю, що в майбутньому clsx може почати підтримувати вирішення конфліктів між класами Tailwind так, як це робить twMerge. Якщо це станеться, то потреба у twMerge відпаде, і залишиться лише clsx.
А як ні, то clsx & twMerge так і будуть міксуватися в cn, як зараз. Подивимось, як все розвиватиметься! 😁

Підписатись на коментарі