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.
1. Use the props first
Section titled “1. Use the props first”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.
2. className and style
Section titled “2. className and style”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);}Why app CSS always wins
Section titled “Why app CSS always wins”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).
What not to do
Section titled “What not to do”- 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
classNameto 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.
3. Composition
Section titled “3. Composition”Many props take React nodes, so a component can be assembled from others without new CSS:
ButtonandTagtakeleftIconandrightIcon.InputtakesleftElementandrightElement.CardhasCardHeaderwith anaction.Dialogtakestriggerandfooter;MenuandPopovertaketrigger.FieldtakeslabelAction;RecordCardtakesavatar,actionsandprimaryAction.
<Input type={show ? 'text' : 'password'} rightElement={ <IconButton aria-label={show ? 'Hide password' : 'Show password'} size="sm" icon={show ? <EyeSlashIcon /> : <EyeIcon />} onClick={toggle} /> }/>4. asChild
Section titled “4. asChild”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.
5. Wrap it
Section titled “5. Wrap it”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.
When to change the library instead
Section titled “When to change the library instead”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
classNameoverride 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.