Skip to content

alouette

A modern, customizable design system built on top of NativeWind v5 with configurable defaults

alouette-styling

315 linesSource

Styling conventions for writing components with alouette: express props keyed on internal state (selected/disabled/loading) or fanning out across slots as tailwind-variants tv() variants instead of a Record lookup, expose a class set the caller could write verbatim through className rather than as an alias variant prop, give one component a single tv() with slots instead of several tv objects, style through className instead of inline style, and write pixel sizes as arbitrary values (w-[380px]) rather than the canonical spacing-scale class. States the alouette design principles every component must satisfy, including that interactivity is a component and never a wrapper. Load when writing, restyling or reviewing a component.

alouette — Styling conventions

How a component's classes are declared and applied. Which classes to use is covered by alouette-theming (tokens), alouette-layout (spacing/radius/shadow) and alouette-typography (text).

Design principles

Every component is measured against these:

  • Consistent UI
  • Unique
  • Professional
  • Accessible
  • Depth using shades, shadows and tint
  • Multi-platform: web and native (ios/android at the moment)
  • Explicit affordance element for interactive surfaces (Chevron icon or action text “View”, “Open”, or “Details”)
  • Animated

Core patterns

State-keyed props are tv() variants; aliases are className

A tv() variant is for a class set the caller cannot write: one keyed on internal state (selected, disabled, loading), or one that fans out across slots or interaction states (PressableBox's variant spreads over interactive-* on hover/focus/active). The keys become the prop's type via VariantProps, and defaultVariants supplies the default.

tsx
import type { VariantProps } from "tailwind-variants";
import { tv } from "../../core/tv"; // merges with alouette's twMerge

const chipVariants = tv({
  base: "rounded-xs min-h-[32px]",
  variants: {
    selected: { true: "bg-emphasis shadow-s", false: "bg-transparent" },
    disabled: { true: "opacity-70" },
  },
  defaultVariants: { selected: false },
});

type ChipVariantProps = VariantProps<typeof chipVariants>;

A variant whose branches are classes the caller could type verbatim (variant: { highlight: "bg-highlight" }, size: { sm: "p-m rounded-sm" }) is an alias: leave it out and let the caller write the class. A prop cannot take a breakpoint prefix, a class can (surface-sm md:surface-lg). A pairing that must never be split becomes a @utility in build-css.ts instead — lowered (the lowered ground + its inset shadow), surface-{xxs…lg} (padding + radius) — and so does a role several components share (surface-popover for the Menu, Select and InputTextAutocomplete panel, surface-flat for the card inside a card), so they match by construction rather than by each call site repeating the same classes. Give each new utility a class group and its conflicts in src/core/twMerge.ts.

Pass the incoming className through the call (chipVariants({ selected, className })) so callers can extend it. Outside a tv(), merge with a tailwind-merge configured for the alouette scale, never by string concatenation (`flex-row ${className}` emits both flex-row and a caller's flex-col and lets stylesheet order pick). A tv() merges a caller's className with that same tailwind-merge: stock tailwind-merge does not know p-m and p-xl conflict, nor that surface carries a padding and a radius. Inside the library that tv is src/core/tv.ts, and the plain tv from tailwind-variants is a lint error. It is createTV({ twMerge: false }) with its output passed through the twMerge of src/core/twMerge.ts, rather than createTV({ twMergeConfig }): tailwind-variants keeps one merge function per process, built from the twMergeConfig of the last tv() created, so a config passed to tv() holds only until another one is. Neither is exported from alouette, so an app component builds its own the same way, with extendTailwindMerge over the same named spacing values and utility class groups.

One component, one tv() — use slots

A component that styles several elements declares one tv() with slots, not one tv() per element. The variant props are then declared once and applied to every element from a single call, so the elements cannot drift.

tsx
const modalVariants = tv({
  slots: {
    panel: "w-full max-h-full",
    inset: "bg-highlight shadow-l",
    footer: "…",
  },
  variants: {
    size: {
      sm: { panel: "max-w-[360px]", inset: "rounded-sm p-sm", footer: "py-xs" },
      md: { panel: "max-w-[520px]", inset: "rounded-sm p-m", footer: "py-m" },
    },
  },
  defaultVariants: { size: "md" },
});

const styles = modalVariants({ size, withFooter: footer !== undefined });
<View className={styles.panel()}>
  <View className={styles.inset()}>…</View>
</View>;

extend still applies for a slot that is a strict superset of another tv (labelVariants = tv({ extend: foregroundVariants, base: "…" })).

className, not inline style

Everything that can be a class is a class — classes participate in theming, variants, group-*/state modifiers and the Tailwind scan. Inline style is only for a value computed at runtime that no static class can express (a measured dimension, windowHeight * 0.7, a token-derived pixel height).

Pixel sizes as arbitrary values

Write a pixel dimension as an arbitrary value — w-[380px], max-w-[460px] — and keep it even when the linter suggests the canonical spacing-scale class (w-95, max-w-115). The scale class hides the actual pixel value, which is the thing being reasoned about. This applies to one-off pixel dimensions only; padding, gap and radius still use the token scale (p-m, gap-xs, rounded-sm).

A control's height is neither: it is a control-* token (min-h-control-md, w-control-md, min-h-control-chip), which DensityScope swaps — 44px comfortable, 36px compact for an interface driven by a mouse. A control written with min-h-[44px] stays 44px in a compact scope. Its label's vertical padding is py-control-inset, so a single line still fits the compact height.

Interactivity is a component, never a wrapper

Nesting a display-only component in a Link or Pressable does not make it interactive: the wrapper takes the role while the visible element keeps none of the interactive-* states, no focus-visible outline and no affordance. Use a pressable component (alouette-actions/SKILL.md), and put the display-only element inside or beside it.

Common Mistakes

HIGH A Record lookup instead of a tv() variant

Wrong:

tsx
const STATE_CLASS = {
  on: "bg-emphasis shadow-s",
  off: "bg-transparent",
} as const;
<Box className={STATE_CLASS[selected ? "on" : "off"]} />;

Correct:

tsx
const chipVariants = tv({
  variants: {
    selected: { true: "bg-emphasis shadow-s", false: "bg-transparent" },
  },
  defaultVariants: { selected: false },
});
<Box className={chipVariants({ selected })} />;

The lookup map duplicates the prop's union type by hand, has no default handling, no compound variants and no className merge.

Source: packages/alouette/src/ui/selection/SegmentedItem.tsx

HIGH An alias variant for a class the caller could write

Wrong:

tsx
const surfaceVariants = tv({
  variants: {
    variant: { surface: "bg-surface", highlight: "bg-highlight" },
    size: { sm: "p-m rounded-sm", md: "p-xl rounded-sm" },
  },
});
<Surface variant="highlight" size="sm" className="py-xs" />;

Correct:

tsx
<Box className="surface bg-highlight surface-sm py-xs md:surface-md" />

Each branch is a class the caller could type, so the prop only hides it: the bundle comes with padding the call site then has to fight, and no branch can be switched at a breakpoint. A bundle worth keeping (padding + radius) is a @utility, which a single class after it still overrides. A component whose only job is those defaults is the same alias one level up — Surface was removed for <Box className="surface"> for this reason.

Source: packages/alouette/scripts/build-css.ts

HIGH Several tv() objects for one component

Wrong:

tsx
const chipVariants = tv({ variants: { selected: …, disabled: … } });
const segmentVariants = tv({ variants: { selected: …, disabled: … } });
const labelVariants = tv({ variants: { selected: …, disabled: … } });

Correct:

tsx
const itemVariants = tv({
  slots: { chip: "…", segment: "…", label: "…" },
  variants: {
    selected: { true: { chip: "opacity-100", label: "text-on-emphasis" } },
  },
});
const styles = itemVariants({ selected, disabled });

Repeating selected/disabled across separate tv objects means every variant change has to be made in three places, and each element is called separately at render time.

Source: packages/alouette/src/ui/containers/Modal.tsx

MEDIUM Inline style for a static value

Wrong:

tsx
<View style={{ maxWidth: 460, paddingHorizontal: 16 }} />

Correct:

tsx
<View className="max-w-[460px] px-m" />

An inline style is invisible to variants, state modifiers and theming, and on web it wins over the class it silently conflicts with.

Source: packages/alouette/src/ui/containers/Modal.tsx

HIGH A base class a variant then has to override

Wrong:

tsx
tv({
  base: "px-xs gap-xxs",
  variants: { variant: { segmented: "", icon: "gap-0" } },
});

Correct:

tsx
tv({
  base: "px-xs",
  variants: {
    variant: { segmented: "gap-xxs", icon: "gap-0" },
  },
});

base holds only what every variant keeps. A property one variant cancels belongs on each variant instead — the neutralizing class (gap-0, p-0, border-0) hides which value actually applies and depends on merge order to win.

Source: packages/alouette/src/ui/selection/SegmentedBar.tsx

MEDIUM Collapsing a pixel size to the spacing-scale class

Wrong:

tsx
<View className="w-95" />
<Pressable className="min-h-[44px]" />

Correct:

tsx
<View className="w-[380px]" />
<Pressable className="min-h-control-md" />

w-95 is the same pixels but reads as a scale step; the explicit arbitrary value keeps the measurement legible. A control's height is the control-md token, which follows the DensityScope; a hardcoded 44px ignores it.

Source: packages/alouette/src/ui/actions/Button.tsx

See also: alouette-theming/SKILL.md (which classes), alouette-layout/SKILL.md (spacing/radius/shadow scale).