On this page

Foundations

Styling

Choose component props, recipes, layout utilities, variables, or themes. Do not override internals.

Luke UI ships static CSS. It does not inject styles at runtime. Import the shared stylesheet and a theme stylesheet once. Then use component props for the variations each component supports.

Choose a styling approach

Work from the most specific public API to the broadest one.

  1. Use a component's props for its supported appearance, size, state, and behaviour.
  2. Use layout utilities for spacing, sizing, and responsive structure around components.
  3. Use semantic variables for application-owned custom UI when no component fits.
  4. Author a custom theme when the product needs a different visual foundation everywhere.

Read Composition when the choice is between a component and a documented primitive rather than between styling mechanisms.

GuidancePractices
DoUse a supported component prop for its appearance, size, state, or behaviour.
DoUse a layout utility for its element contract, and vars for application-owned custom UI.
Don'tTarget generated selectors or undocumented state attributes.

Do not override component recipe selectors or target state attributes unless the component documents that attribute as part of its supported DOM contract. Many React Aria attributes are implementation details and can change without a migration path. When a component deliberately exposes a state attribute for styling or integration, treat it as public for that component only.

Start with component props

Component props cover supported variants, states, and behaviour. The active identity supplies semantic colours, font roles, radii, and shadow. Luke UI defines its type and spacing scale in source.

import { Button } from '@luke-ui/react/button';

export function SaveButton() {
	return <Button size="small">Save changes</Button>;
}

Apply a component recipe

Import a recipe when you own the element and need that component's visual treatment. Use the component when you want its behaviour as well.

Use a recipe only when the component cannot render the element you need to style. For button-shaped navigation, prefer Link with appearance="button" over applying buttonRecipe to an anchor.

Each recipe lives on the component or primitive entrypoint that owns it. Import buttonRecipe from @luke-ui/react/button. There is no @luke-ui/react/recipes barrel.

Recipes follow a fixed name. The function is the component in camel case plus Recipe. Recipes with variants also export a type named after the component in Pascal case plus RecipeVariants.

A single-part recipe such as buttonRecipe returns the finished class string. Pass the same variant names the component accepts, plus an optional className of your own. The recipe appends your class after its own, so do not wrap the result in another composition helper.

import { buttonRecipe, type ButtonRecipeVariants } from '@luke-ui/react/button';

const variants: ButtonRecipeVariants = {
	appearance: 'button',
	tone: 'neutral',
	prominence: 'standard',
};
const className = buttonRecipe({ ...variants, className: 'my-class' });

Recipes that style a component's private multi-part anatomy stay package-private. Compose the public primitives instead of importing those recipes.

A primitive's recipe imports from the primitive's entrypoint. textInputRecipe styles an input you own:

import { textInputRecipe } from '@luke-ui/react/primitives/text-input';

<input aria-label="Example input" className={textInputRecipe({ size: 'medium' })} />;

Do not import a recipe to restyle a Luke UI component from the outside. Use the component's props for supported variation.

Use layout components for structure

Box and the other layout components handle spacing, sizing, positioning, and responsive structure around components. They do not set semantic colour, typography, or pseudo states. Read Layout for the full API.

Size elements outside the utility values

Layout sizing props accept keywords such as 100% and fit-content, and maxInlineSize accepts the container.* presets. They do not accept arbitrary lengths. Put a fixed length on style. For a size that changes at a breakpoint, write CSS with a @container query built from breakpoints in @luke-ui/react/theme, then pass it as className.

import { Box } from '@luke-ui/react/box';
import type { PropsWithChildren } from 'react';
import { sidebar } from './responsive-size.css';

export function Sidebar({ children }: PropsWithChildren) {
	return (
		<Box className={sidebar} flexShrink="0">
			{children}
		</Box>
	);
}

An unnamed @container query measures the nearest ancestor size container, the same one responsive layout props use. That is the document root unless a Container sits closer. Do not swap it for a @media query, which measures the viewport instead.

Use semantic variables for custom UI

The public vars token contract lets an application-owned element follow the active theme.

import { vars } from '@luke-ui/react/theme';
import type { PropsWithChildren } from 'react';

export function FloatingPanel({ children }: PropsWithChildren) {
	return (
		<aside
			style={{
				backgroundColor: vars.color.background.surface.subdued,
				borderRadius: vars.radius.surface,
				boxShadow: vars.shadow.floating,
				color: vars.color.foreground.primary,
			}}
		>
			{children}
		</aside>
	);
}

Use interactionState('hover') or interactionState('pressed') from @luke-ui/react/utils in a state style. The utility paints the active theme's tint as a registered background layer over the element's resting fill. Keep the resting backgroundColor in the rule.

import { vars } from '@luke-ui/react/theme';
import { interactionState } from '@luke-ui/react/utils';
import { style } from '@vanilla-extract/css';

const interactionButton = style({
	backgroundColor: vars.color.background.neutral.subtle,
	color: vars.color.foreground.primary,
	selectors: {
		'&:hover': interactionState('hover'),
		'&:active': interactionState('pressed'),
	},
});

export function InteractionStateExample() {
	return <button className={interactionButton}>Hover state</button>;
}

A background you set with vars does not set the text colour for Text inside it, so set the colour in the same rule. Use a Box backgroundColor when the element should set it for you. Read Colour for the difference.

The token contract is public. Component selectors, generated palette values, and theme implementation details are not. See the token reference for the full contract.

Change the visual foundation with a custom theme

Use a custom theme when a product needs a different identity, typeface, or semantic colour system. defineTheme produces a static stylesheet for the full semantic contract from a curated accent and neutral character. It is not a per-component override tool.

Set up static styles

Read Applying a theme for stylesheet imports, the identity class, and colour modes.

The shared stylesheet gives the whole document a small baseline:

  • Document. <body> gets the theme's base surface, text colour, accent colour, and body font, a unitless 1.5 line height, and no margin. Its background fills the page, including short pages and overscroll.
  • Box sizing. Every element and pseudo-element uses border-box.
  • Form controls. Buttons, inputs, selects, and text areas inherit the surrounding font and have no margin. They keep their native appearance otherwise.
  • Focus. Keyboard focus shows a 2px ring in the theme's focus colour, offset by 2px. In forced colours mode it uses the system highlight colour.
  • Container queries. The root element is an inline-size container, so an unnamed @container query measures the page width.

Everything else keeps its browser presentation. Headings keep their sizes, lists keep their markers, and paragraphs, tables, and native buttons keep their spacing. Luke UI components keep their own appearance beside native markup. The stylesheet does not change animations, including under reduced motion. Each Luke UI component adapts its own motion.

Override any of these defaults with your own CSS, for example a body rule in your stylesheet. Component state attributes such as data-focus-visible and data-pressed are implementation details, not styling hooks.

Use application CSS alongside Luke UI

Luke UI's stylesheet puts every rule in cascade layers. It declares a base layer for the application, followed by a luke-ui layer for its own styles:

@layer base, luke-ui;

Luke UI never writes to base. Put application defaults, such as a CSS reset, in base so Luke UI component styles outrank them. Styles in a layer declared after luke-ui, and styles in no layer, outrank all of Luke UI.

Tailwind CSS

Declare one layer order before importing Tailwind or Luke UI:

@layer theme, base, luke-ui, components, utilities;

@import 'tailwindcss';
@import '@luke-ui/react/stylesheet.css';
@import '@luke-ui/theme-tactile/stylesheet.css';

Without that statement, import order decides whether Tailwind utilities or Luke UI styles win. With it, Tailwind Preflight stays below Luke UI components, and Tailwind utilities override Luke UI component styles. Preflight still resets native HTML on its own, for example heading sizes and list markers outside Prose.

Application styles

Tailwind utilities, CSS Modules, and application CSS work well for application-owned layout and custom elements. Put Tailwind classes on surrounding elements or Box. Use CSS Modules or application styles with public semantic variables for custom UI. Unlayered application CSS sits outside Luke UI's layers, so do not use it to reach into a component's internal DOM.

import { Box } from '@luke-ui/react/box';

<Box className="mx-auto max-w-5xl" paddingInline="space.16">
	{children}
</Box>;

When a CSS Module or application stylesheet needs a token, use the stable --luke-* variable listed in the token reference. Do not couple application CSS to undocumented selectors or implementation attributes.

Continue learning