Handbook
Styling
The rules to style a screen with token classes, props and the utility classes of ferry-ui.
Handbook
The rules to style a screen with token classes, props and the utility classes of ferry-ui.
ferry-ui uses Tailwind CSS v4. Your markup can use the same classes as the components.
If your app does not use Tailwind, use the CSS variables of the tokens in your own CSS.
A token is a named design value: a color, a radius, a shadow or a font. Each token is a CSS variable and a Tailwind class.
The color tokens change with the theme. Do not write a raw color, a class of the Tailwind palette or a dark: variant.
Web app
Owner: Maya Chen
Billing portal
Owner: Sam Lee
Help center
Owner: Maya Chen
const PROJECTS = [
{ id: 'web', name: 'Web app', owner: 'Maya Chen', invoices: 12 },
{ id: 'billing', name: 'Billing portal', owner: 'Sam Lee', invoices: 4 },
{ id: 'docs', name: 'Help center', owner: 'Maya Chen', invoices: 0 },
]
export default function TokenClasses() {
return (
<ul className="w-full max-w-sm divide-y rounded-lg border bg-surface-100">
{PROJECTS.map((project) => (
<li key={project.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm text-foreground">{project.name}</p>
<p className="truncate text-[13px] text-foreground-light">Owner: {project.owner}</p>
</div>
<span className="tabular text-[13px] text-foreground-lighter">{project.invoices} invoices</span>
</li>
))}
</ul>
)
}| Do not write | Write |
|---|---|
bg-white | bg-surface-100 |
text-gray-600 | text-foreground-light |
border-[#e5e5e5] | border |
dark:bg-… | One token class |
font-bold | font-medium |
The Tokens page shows each token.
A control gets its colors from the variant prop or the tone prop. Do not set a color, a height, a padding or a font size on a control with className.
import { Badge, Button, StatusBadge } from 'ferry-ui'
export default function LookFromProps() {
return (
<>
<Button variant="primary">Save changes</Button>
<Button variant="destructive">Delete project</Button>
<Badge variant="outline" font="mono" shape="square">
v2.4.1
</Badge>
<StatusBadge tone="success" label="Paid" />
<StatusBadge tone="warning" label="Expires soon" />
</>
)
}The size prop sets the height. A field and a button of the same size have the same height.
import { Button, Input } from 'ferry-ui'
const SIZES = [
{ size: 'tiny', height: '26px' },
{ size: 'sm', height: '30px' },
{ size: 'md', height: '34px' },
{ size: 'lg', height: '38px' },
] as const
export default function ControlSizes() {
return (
<div className="flex flex-col gap-3">
{SIZES.map(({ size, height }) => (
<div key={size} className="flex items-center gap-2">
<span className="w-20 font-mono text-xs text-foreground-lighter">
{size}, {height}
</span>
<Input size={size} aria-label={`Coupon code, size ${size}`} placeholder="Coupon code" className="w-40" />
<Button size={size}>Apply</Button>
</div>
))}
</div>
)
}| Height | Button | Input | SelectTrigger | Toggle |
|---|---|---|---|---|
| 26px | tiny | tiny | tiny | tiny |
| 30px | sm (default) | sm | sm | sm (default) |
| 34px | md | md (default) | md (default) | md |
| 38px | lg | lg | None | None |
On a control, className is for layout: a margin, a width or a position in a grid.
import { Button, Input } from 'ferry-ui'
export default function LayoutClasses() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex items-center gap-2">
{/* `flex-1`: the field takes the space that the button leaves. */}
<Input type="email" aria-label="Email of the member" placeholder="maya@example.com" className="flex-1" />
<Button size="md">Invite</Button>
</div>
{/* `max-w-48`: a short field for a short value. */}
<Input mono aria-label="Project slug" defaultValue="billing-portal" className="max-w-48" />
{/* `w-full`: the button takes the full width of the column. */}
<Button size="md" variant="primary" className="w-full">
Create project
</Button>
</div>
)
}A container accepts token classes. Examples are the parts of Card, a cell of Table and Avatar.
import { Avatar, AvatarBadge, AvatarFallback, Card, CardContent, CardFooter, CardHeader, CardTitle } from 'ferry-ui'
export default function ContainerClasses() {
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Workspace</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-3">
<Avatar className="rounded-md">
<AvatarFallback>AC</AvatarFallback>
<AvatarBadge className="bg-success" role="img" aria-label="Active" />
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm text-foreground">Acme</span>
<span className="truncate text-[13px] text-foreground-light">12 members, 3 projects</span>
</div>
</CardContent>
<CardFooter className="justify-between text-[13px] text-foreground-light">
<span>Next invoice</span>
<span className="tabular text-foreground">Nov 1</span>
</CardFooter>
</Card>
)
}cn() joins class names. If two Tailwind classes set the same property, the last class wins.
Each component adds your className after its own classes. As a result, your class wins. Use cn() in your code for a class that depends on a condition.
import * as React from 'react'
import { Checkbox, Label, cn } from 'ferry-ui'
const MEMBERS = [
{ id: 'maya', name: 'Maya Chen', role: 'Admin' },
{ id: 'sam', name: 'Sam Lee', role: 'Member' },
{ id: 'noor', name: 'Noor Haddad', role: 'Viewer' },
]
export default function MergeClasses() {
const [selected, setSelected] = React.useState(['maya'])
function toggle(id: string, checked: boolean) {
setSelected((current) => (checked ? [...current, id] : current.filter((item) => item !== id)))
}
return (
<ul className="w-full max-w-xs divide-y rounded-lg border bg-surface-100">
{MEMBERS.map((member) => {
const checked = selected.includes(member.id)
return (
<li key={member.id} className={cn('flex items-center justify-between gap-3 px-4 py-3', checked && 'bg-selection')}>
<Label>
<Checkbox checked={checked} onCheckedChange={(next) => toggle(member.id, next === true)} />
{member.name}
</Label>
{/* The last class wins: `text-foreground` replaces `text-foreground-lighter`. */}
<span className={cn('text-[13px] text-foreground-lighter', checked && 'text-foreground')}>{member.role}</span>
</li>
)
})}
</ul>
)
}The theme of ferry-ui adds four utility classes.
mono-labelMonthly revenuetabular1,111.10
9,876.54
bg-dot-gridscrollbar-noneconst REGIONS = ['eu-west', 'eu-central', 'us-east', 'us-west', 'ap-south', 'ap-northeast', 'sa-east']
export default function Utilities() {
return (
<div className="grid w-full max-w-lg gap-x-8 gap-y-6 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<code className="text-xs text-foreground-lighter">mono-label</code>
<span className="mono-label">Monthly revenue</span>
</div>
<div className="flex flex-col gap-2">
<code className="text-xs text-foreground-lighter">tabular</code>
<div className="tabular w-24 text-right text-sm text-foreground">
<p>1,111.10</p>
<p>9,876.54</p>
</div>
</div>
<div className="flex flex-col gap-2">
<code className="text-xs text-foreground-lighter">bg-dot-grid</code>
<div className="bg-dot-grid h-16 rounded-lg border" />
</div>
<div className="flex min-w-0 flex-col gap-2">
<code className="text-xs text-foreground-lighter">scrollbar-none</code>
{/* The list scrolls sideways with a trackpad, a touch screen or the arrow keys. */}
<div
tabIndex={0}
role="region"
aria-label="Regions"
className="scrollbar-none overflow-x-auto rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ul className="flex gap-2">
{REGIONS.map((region) => (
<li key={region} className="shrink-0 rounded-sm border bg-surface-100 px-2 py-1 font-mono text-xs text-foreground-light">
{region}
</li>
))}
</ul>
</div>
</div>
</div>
)
}| Class | Effect |
|---|---|
mono-label | A small uppercase caption in the mono font. The look of Mono Label. |
tabular | Digits of one width, for numbers in a column. |
bg-dot-grid | A background with a grid of dots. |
scrollbar-none | No scrollbar. The element continues to scroll. |
Each control shows a ring when it gets the keyboard focus. Give the same ring to your own interactive elements.
<button className="outline-none focus-visible:ring-2 focus-visible:ring-ring" />Press Tab to move the focus to this custom button.
import { toast } from 'ferry-ui'
import { ImagePlus } from 'lucide-react'
export default function FocusRing() {
return (
<button
type="button"
onClick={() => toast('Choose an image for the logo')}
className="flex w-full max-w-xs cursor-pointer flex-col items-center gap-2 rounded-lg border border-dashed border-border-stronger px-6 py-8 text-[13px] text-foreground-light outline-none hover:bg-surface-200 focus-visible:ring-2 focus-visible:ring-ring"
>
<ImagePlus className="size-5 text-foreground-lighter" aria-hidden="true" />
Add a logo
</button>
)
}| Subject | Rule |
|---|---|
| Font weight | font-normal and font-medium only. No bold text. |
| Border | A 1px border gives structure to cards and tables. |
| Shadow | shadow-overlay is for overlays only. |
| Radius | rounded-md for controls, rounded-lg for containers. |
| Color | Neutral surfaces. Color shows a meaning. |
| Icons | Icons come from lucide-react. The component sets their size. |