Skip to content

Styling and extending

Components have a small, fixed API on purpose. This page lists the ways to adjust one inside an app, from lightest to heaviest, and where the line is between an app-level adjustment and a change to the library.

Most differences between two uses of a component are already a prop: variant, size, tone, padding, fullWidth. Check the component’s page before writing CSS. For spacing between components, use Flex, Stack and Grid rather than margins.

Every component passes className and style to its root element. Use them for one-off layout: a width, a margin, a position.

Tour departs in 2 days

`className` and `style` go to the root element of every component.

import { Button, Card, Text } from '@beyul/sulmu-ui'
export default function Example() {
return (
<Card
padding="md"
outlined
// Tokens are plain CSS variables, so app CSS can use them directly.
style={{ maxWidth: 360, borderColor: 'var(--su-colors-brand-solid)' }}
>
<Text textStyle="label">Tour departs in 2 days</Text>
<Text textStyle="bodyS" tone="muted" style={{ marginBlock: 'var(--su-spacing-2)' }}>
`className` and `style` go to the root element of every component.
</Text>
<Button size="sm" style={{ minWidth: 160 }}>
View itinerary
</Button>
</Card>
)
}

The tokens are CSS variables, so app CSS can stay on the same scale:

.booking-summary {
padding: var(--su-spacing-6);
border-radius: var(--su-radii-xl);
background: var(--su-colors-bg-muted);
}

The library’s stylesheet is inside CSS cascade layers. Any CSS that is not in a layer beats layered CSS, whatever the specificity. A plain class is enough to override a component:

/* Wins over .su-button, without !important */
.wide-button {
min-width: 200px;
}

One thing to watch: if the app puts its own reset in a layer, the order of the layers decides who wins. Declare the app’s layers first so the library’s come later:

@layer app-reset, reset, base, tokens, recipes, utilities;

This site does exactly that for Starlight’s styles (docs/src/styles/layers.css).

  • Do not restyle the inside of a component by targeting its classes (.my-form .su-input__field). Those class names follow the recipes and can change in any release.
  • Do not use className to change colours, radius or type of a component across the app. That is a new variant, and it belongs in the library and in Figma.

Many props take React nodes, so a component can be assembled from others without new CSS:

  • Button and Tag take leftIcon and rightIcon.
  • Input takes leftElement and rightElement.
  • Card has CardHeader with an action.
  • Dialog takes trigger and footer; Menu and Popover take trigger.
  • Field takes labelAction; RecordCard takes avatar, actions and primaryAction.
<Input
type={show ? 'text' : 'password'}
rightElement={
<IconButton
aria-label={show ? 'Hide password' : 'Show password'}
size="sm"
icon={show ? <EyeSlashIcon /> : <EyeIcon />}
onClick={toggle}
/>
}
/>

Button, BackButton and SideNavItem accept asChild. The component then renders its child element instead of its own, with its styles and props merged onto it. Use it to make a router link look like a button:

import { Link } from 'react-router-dom'
<Button variant="secondary" asChild>
<Link to="/accounts">Open accounts</Link>
</Button>
<SideNavItem asChild active>
<Link to="/admin/users">Users</Link>
</SideNavItem>

With asChild the child provides all of the content: Button does not add leftIcon, rightIcon or the spinner, and BackButton does not add its chevron.

When an app uses a component the same way in many places, wrap it once. The wrapper fixes some props and passes the rest through, so it stays a library component underneath.

import { Button, TrashIcon, type ButtonProps } from '@beyul/sulmu-ui'
/** An app-level button with a fixed meaning. It still accepts every Button prop. */
function DeleteButton({ children = 'Delete', ...rest }: Omit<ButtonProps, 'variant'>) {
return (
<Button variant="destructive" leftIcon={<TrashIcon />} {...rest}>
{children}
</Button>
)
}
export default function Example() {
return <DeleteButton size="sm">Delete booking</DeleteButton>
}

Keep wrappers in the app when they carry app meaning (a DeleteButton, a CountryField with the app’s list of countries). They should not add styling of their own.

Change the library, not the app, when any of these is true:

  • The designer has drawn a new variant, size or state in Figma.
  • The same className override appears in more than one screen.
  • A second module needs the same wrapper.
  • The component is missing an accessibility feature or has a bug.
  • You need a generic component that does not exist yet.

The reason is the one the library exists for: a fix made in an app helps one screen, and a fix made in the library helps every module and keeps Figma and code in step. See Adding a component.