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.
- Use a component's props for its supported appearance, size, state, and behaviour.
- Use layout utilities for spacing, sizing, and responsive structure around components.
- Use semantic variables for application-owned custom UI when no component fits.
- 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.
| Guidance | Practices |
|---|---|
| Do | Use a supported component prop for its appearance, size, state, or behaviour. |
| Do | Use a layout utility for its element contract, and vars for application-owned custom UI. |
| Don't | Target 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 unitless1.5line 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
@containerquery 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.