liquiddesign

Architektura komponentów

Zasady mieszkają w komponentach

Zasady mówią, że interfejs ma stać w miejscu. Architektura decyduje, czy to się uda: jeśli o rezerwie na błąd i wymiarach skeletonu musi pamiętać każdy widok osobno, przegrasz przy trzecim sprincie. Jeśli są wbudowane w komponent, dobre nawyki przychodzą same. Ta strona porządkuje, jak dzielić interfejs na warstwy, jak nazywać ich elementy i jak projektować API komponentu, żeby liquid design był właściwością systemu, a nie dyscypliną jednostek.

Trzy warstwy zamiast pięciu atomów

Atomic design Brada Frosta nauczył branżę składania interfejsu z małych części. Bierzemy z niego to, co działa, i zostawiamy to, co w praktyce produkuje dyskusje zamiast komponentów.

Bierzemy

  • Hierarchię składania: małe rzeczy budują większe, nigdy odwrotnie.
  • Wspólny język zespołu: designer i programista mówią o tym samym poziomie abstrakcji.
  • Inwentarz interfejsu: zanim narysujesz widok, wiesz, z czego on powstanie.

Zostawiamy

  • Pięć poziomów: granica między molekułą a organizmem to najczęstsza jałowa dyskusja w historii design systemów.
  • Kategoryzację dla samej kategoryzacji: przenoszenie komponentu między folderami nie poprawia produktu.
  • Metaforę chemiczną w nazwach folderów: atoms/ mówi mniej niż ui/.

Zamiast atomów, molekuł i organizmów wystarczą trzy warstwy z jedną regułą: zależności płyną wyłącznie w dół. Tak jest zbudowana także ta platforma.

ui/

Prymitywy

Pojedyncze elementy interfejsu: przycisk, pole, badge, skeleton. Nie znają domeny, nie wiedzą, w jakim widoku stoją, a wszystkie swoje stany mają wbudowane. Zależą wyłącznie od tokenów.

ButtonInputBadgeSkeleton

components/

Kompozycje

Powtarzalne bloki produktu sklejone z prymitywów: szkielet lekcji, blok kodu, ramka demo. Znają konwencje platformy, ale nadal nie znają konkretnej treści. Zależą od prymitywów i tokenów.

LessonShellCodeBlockDemoFrame

app/

Widoki

Strony i layouty. Układają kompozycje w siatki, ustalają odstępy i wstawiają treść. Niczego nie stylują od nowa: jeśli widok potrzebuje nowego wyglądu, brakuje wariantu piętro niżej.

page.tsxlayout.tsx

BEM to sposób myślenia, nie składnia

BEM rozwiązywał problem globalnego CSS: skoro wszystko widzi wszystko, scope trzeba było utrzymać konwencją nazw. W świecie komponentów scope daje narzędzie, ale rozbiór interfejsu na blok, element i modyfikator pozostaje aktualny. Zmienia się tylko miejsce, w którym te pojęcia mieszkają.

Blok

.badge

Komponent

<Badge>

Element

.badge__icon

Markup wewnątrz komponentu

span w JSX

Modyfikator

.badge--danger

Prop wariantu

tone="danger"

Scope przez konwencję nazw

dyscyplina zespołu

Scope przez granicę komponentu

gwarantuje narzędzie

BEM: scope utrzymuje konwencja nazw
.badge {
  display: inline-flex;
  border-radius: 999px;
  padding: 0.25rem 0.75rem;
}
.badge__icon { width: 1em; }
.badge--danger {
  border-color: var(--color-danger-400);
  color: var(--color-danger-300);
}

<span class="badge badge--danger">
  <svg class="badge__icon"></svg>
  Błąd zapisu
</span>
Komponent: scope utrzymuje granica komponentu
const tones = {
  neutral: "border-ink-600 bg-ink-800 text-mist-300",
  danger: "border-danger-400/30 bg-danger-400/10 text-danger-300",
} as const;

export type BadgeTone = keyof typeof tones;

export function Badge({ tone = "neutral", children }) {
  return (
    <span className={`inline-flex rounded-full border px-3 py-1 ${tones[tone]}`}>
      {children}
    </span>
  );
}

Klasyczny BEM nadal wygrywa tam, gdzie nie ma bundlera ani komponentów: projekt w czystym HTML i CSS, szablony CMS, strony bez kroku budowania. Wszędzie tam to wciąż najlepsza dostępna dyscyplina.

Kontrakt komponentu

API komponentu to obietnica składana każdemu widokowi, który go użyje. Cztery reguły utrzymują tę obietnicę w mocy niezależnie od tego, kto i kiedy komponent wywoła.

Wygląd z zamkniętej listy

Warianty to enumeracja: tone, variant, size jako klucze mapy, nie wolne stringi. Typ podpowiada dostępne opcje, a nowy wygląd wymaga świadomej decyzji w jednym miejscu zamiast doraźnej klasy w widoku.

Stan to boolean, wygląd to wariant

loading, disabled i error opisują, co się dzieje. primary i danger opisują, czym komponent jest. Mieszanie tych osi kończy się propsami w rodzaju isRedButton, których nikt nie umie użyć poprawnie.

Kompozycja zamiast konfiguracji

Treść wchodzi przez children, nie przez rosnącą listę propsów w rodzaju iconLeft, subtitle i footerText. Komponent z dziesięcioma propsami konfiguracyjnymi to zwykle trzy komponenty sklejone na siłę.

Zero marginesów zewnętrznych

Komponent kończy się na własnej krawędzi. Odstępy między elementami należą do rodzica i jego gap, bo tylko rodzic zna kontekst. Badge z wbudowanym margin-bottom psuje każdy layout poza tym jednym, dla którego powstał.

Stan loading dzieli komórkę grida z treścią, wymiary stoją
<button
  aria-busy={loading}
  className="inline-grid min-h-11 place-items-center rounded-full px-6"
>
  <span className={`col-start-1 row-start-1 ${loading ? "invisible" : ""}`}>
    {children}
  </span>
  <span
    aria-hidden
    className={`col-start-1 row-start-1 ${loading ? "" : "invisible"}`}
  >
    <Spinner />
  </span>
</button>
Odstępy należą do rodzica; margines w komponencie to antywzorzec
<section className="flex flex-col gap-4">
  <Badge tone="ok">Gotowe</Badge>
  <Button>Zapisz zmiany</Button>
</section>

.badge { margin-bottom: 16px; }

Reguły architektury

  • Zależności płyną w dół: ui/ nie importuje z components/, components/ nie importuje z app/. Import w drugą stronę to sygnał, że komponent stoi na złej warstwie.
  • Zasady platformy mieszkają w komponentach, nie w code review: miejsce na błąd, stałe wymiary stanu ładowania i cele dotyku są wbudowane w prymityw, więc widok dostaje je za darmo.
  • Każdy stan komponentu ma te same wymiary: treść, ładowanie i błąd dzielą jeden szkielet, bo przełączenie stanu nie ma prawa ruszyć layoutu.
  • Komponent nie zna swojego kontekstu: zero marginesów zewnętrznych, zero założeń o szerokości rodzica, rozmiar dyktuje kontener.
  • Abstrakcja przy trzecim użyciu: skopiować kod dwa razy jest tańsze niż utrzymywać zły komponent współdzielony, którego API zgadywało przyszłość.
  • Jedna nazwa wszędzie: komponent nazywa się tak samo w kodzie, w Figmie i w rozmowie zespołu, bo tłumaczenie nazw to podatek płacony przy każdym przekazaniu.