Skip to content

Instantly share code, notes, and snippets.

@ftes
Created February 21, 2026 08:47
Show Gist options
  • Select an option

  • Save ftes/a6a78969187223637f71662ed593d8f8 to your computer and use it in GitHub Desktop.

Select an option

Save ftes/a6a78969187223637f71662ed593d8f8 to your computer and use it in GitHub Desktop.
Component library design patterns extracted from Adam Wathan's "Designing a Component Library" talk (Laracon US 2024). Covers composition over props, data-slot attributes, CSS grid input groups, isolate for z-index, subgrid alignment, responsive touch targets, and CSS custom properties as responsive props.

Prefer Composition Over Props

Don't bundle label + input + description into a single component with props for every variation (size, descriptionPlacement, iconLeft, iconRight, layout). This leads to "prop city" — an ever-growing API that can never anticipate every use case.

Instead, expose composable child components:

<!-- ❌ Monolithic prop-driven API -->
<Input label="Price" description="Set your price" icon="dollar" icon-right="help" description-placement="top" size="md" layout="horizontal" />

<!-- ✅ Composable compound components -->
<Field>
  <Label>Price</Label>
  <Description>Set your price</Description>
  <InputGroup>
    <DollarIcon />
    <Input class="sm:max-w-32" />
    <HelpIcon />
  </InputGroup>
</Field>

This is more verbose but far more flexible. Layout, ordering, and responsive behavior are controlled by the consumer, not baked into the component.

Use data-slot Attributes for Parent-Child Styling

Mark each component's role with a data-slot attribute so parent components can target children without knowing the underlying HTML element:

<!-- Components self-identify their role -->
<label data-slot="label" ...>
<input data-slot="control" ...>
<select data-slot="control" ...>  <!-- Same slot, different element -->
<p data-slot="description" ...>
<svg data-slot="icon" ...>

Parent styles target slots, not elements:

/* Field component styles */
[data-slot="control"] + [data-slot="description"] { margin-top: 0.5rem; }

This decouples the parent from specific child implementations — a <select> and <input> both work as data-slot="control" without the parent knowing which is used.

Use CSS Grid + Overlapping Columns for Input Groups (Icons)

Build input groups (input with leading/trailing icons) using CSS grid where the input and icons share the same grid area:

  • Define a 3-column grid: grid-template-columns: var(--spacing-10) 1fr var(--spacing-10)
  • Place the input spanning all 3 columns, row 1
  • Place icons in column 1 or 3, row 1 (overlapping the input)
  • Use sibling selectors to add padding to the input when icons are present:
    • [data-slot="icon"] + [data-slot="control"] → add padding-left
    • Use CSS :has() for forward-sibling targeting: &:has([data-slot="control"] + [data-slot="icon"]) [data-slot="control"] → add padding-right

Use class as a "Sharp Knife"

Expose class on components for contextual/layout concerns only:

  • ✅ Margins (never bake margins into components)
  • ✅ Max-width, width constraints
  • ✅ Responsive layout overrides
  • ❌ Don't use it to override internal styling

Use isolate to Fix Z-Index Problems

Instead of z-index wars, use CSS isolation: isolate (Tailwind class: isolate) to create stacking context sandboxes:

<nav class="sticky z-10">...</nav>
<main class="isolate">
  <!-- Any z-index inside here is scoped; won't bleed above the nav -->
</main>

This has been supported since Safari 8. Almost eliminates the need for high z-index values. You rarely need more than z-0, z-1, and z-10 when wrapping sections in isolate and letting DOM order handle the rest.

Use subgrid for Cross-Component Alignment

When dropdown/menu items need aligned columns (e.g., optional icons + text), use CSS subgrid:

  • Parent (menu): display: grid; grid-template-columns: auto 1fr;
  • Each child item: grid-template-columns: subgrid; grid-column: span 2;
  • Text label: grid-column-start: 2 (always starts in second column)

This makes items with no icon still align their text with items that have icons — and if no items have icons, the icon column collapses to zero width automatically.

Responsive Touch Targets with max() and Pointer Media Queries

Create invisible touch targets that expand hit areas on touch devices:

<button class="relative">
  <span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
               size-[max(100%,44px)]
               [@media(pointer:fine)]:hidden"></span>
  <!-- visible content -->
</button>
  • size-[max(100%,44px)] ensures minimum 44×44px touch target (Apple HIG recommendation)
  • @media(pointer:fine) hides the expanded target for mouse users — screen size is the wrong heuristic; pointer precision is what matters.

Use CSS Custom Properties as "Responsive Props"

When a prop value needs to change at different breakpoints, represent it as a CSS variable instead of a component prop:

<!-- ❌ Props can't change responsively -->
<Table gutter="6" />

<!-- ✅ CSS variables can -->
<div class="[--gutter:var(--spacing-6)] sm:[--gutter:var(--spacing-10)]">
  <Table />
</div>

The component reads var(--gutter) internally. This also works for boolean-like behavior using calc() with 0/1 multiplication:

/* Conditional padding: apply gutter only when --bleed is 0 */
padding-inline: calc(var(--gutter) * (1 - var(--bleed, 0)));

/* Edge padding: add difference between gutter and base when --bleed is 1 */
--edge-padding: calc(var(--spacing-1) + var(--bleed, 0) * (var(--gutter) - var(--spacing-1)));

This enables responsive full-bleed tables that can bleed on mobile but respect page padding on desktop — impossible with JS props, trivial with CSS variables.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment