Handbook
Composition
How the components of ferry-ui go together with parts, asChild, slot props and state props.
Handbook
How the components of ferry-ui go together with parts, asChild, slot props and state props.
ferry-ui has three layers of components. A primitive is one control or one container. A pattern puts primitives together for a frequent need. A layout component makes the frame of the app.
Many components are a group of parts. Import the parts. Nest them. Leave out a part that you do not need.
import { Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from 'ferry-ui'
export default function Parts() {
return (
<Card className="w-full max-w-sm">
<CardHeader>
<div>
<CardTitle>Payment method</CardTitle>
<CardDescription>The charge date is the first day of each month.</CardDescription>
</div>
<CardAction>
<Button size="tiny">Replace</Button>
</CardAction>
</CardHeader>
<CardContent className="text-sm">Card ending in 4242</CardContent>
<CardFooter className="justify-between text-[13px] text-foreground-light">Next charge on Nov 1</CardFooter>
</Card>
)
}DialogTrigger renders a <button>. With asChild, it renders no element of its own. It gives its behavior to its child.
Use asChild to make a Button the trigger of a Dialog, a menu or a popover.
import {
Button,
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Kbd,
} from 'ferry-ui'
export default function AsChildTrigger() {
return (
<Dialog>
{/* The trigger adds its behavior to the Button. The page gets one <button>, not two. */}
<DialogTrigger asChild>
<Button>Keyboard shortcuts</Button>
</DialogTrigger>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>Keyboard shortcuts</DialogTitle>
<DialogDescription>These keys work on each page.</DialogDescription>
</DialogHeader>
<DialogBody>
<p className="flex items-center justify-between text-[13px] text-foreground-light">
Close a dialog <Kbd>Esc</Kbd>
</p>
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button variant="primary">Done</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Button also accepts asChild. It then gives its look to its child, for example a link.
import { Button } from 'ferry-ui'
import { ExternalLink } from 'lucide-react'
export default function AsChildLink() {
return (
// The Button gives its look to the link. The page gets an <a>, not a <button>.
<Button asChild iconRight={<ExternalLink />}>
<a href="https://github.com/Carter2307/ferry-ui" target="_blank" rel="noreferrer">
Open the repository
</a>
</Button>
)
}A slot is a prop that accepts an element. The component puts the element in the correct place.
import { Badge, ResourceCard, StatusLine } from 'ferry-ui'
import { FolderKanban } from 'lucide-react'
export default function Slots() {
return (
<ResourceCard
as="div"
className="w-full max-w-xs"
name="Billing portal"
icon={<FolderKanban />}
subtitle="Owner: Maya Chen"
badges={
<Badge font="mono" shape="square">
Pro
</Badge>
}
footer={<StatusLine tone="success">The project is active</StatusLine>}
/>
)
}| Slot prop | Components |
|---|---|
icon | Button, EmptyState, ResourceCard |
actions | PageHeader, EmptyState, Callout |
footer | FormCard, ResourceCard, IconRail |
trigger | ConfirmDialog, MobileNav |
topBar, rail, mobileNav | AppShell |
A component with a state has two modes. In the uncontrolled mode, the component holds the state. A prop such as defaultValue gives the first value.
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'ferry-ui'
export default function Uncontrolled() {
return (
// `defaultValue` gives the first tab. After that, Tabs holds the state.
<Tabs defaultValue="overview" className="w-full max-w-md">
<TabsList aria-label="Project sections">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="invoices">Invoices</TabsTrigger>
<TabsTrigger value="members">Members</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="text-[13px] text-foreground-light">
Billing portal has 3 open invoices and 6 members.
</TabsContent>
<TabsContent value="invoices" className="text-[13px] text-foreground-light">
INV-2041, INV-2042 and INV-2043 are open.
</TabsContent>
<TabsContent value="members" className="text-[13px] text-foreground-light">
Maya Chen, Sam Lee and 4 more members.
</TabsContent>
</Tabs>
)
}In the controlled mode, your code holds the state. Pass the value and a function that gets each change.
import * as React from 'react'
import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from 'ferry-ui'
export default function Controlled() {
// The state is in your code: you read it and you change it.
const [tab, setTab] = React.useState('overview')
return (
<Tabs value={tab} onValueChange={setTab} className="w-full max-w-md">
<TabsList aria-label="Project sections">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="invoices">Invoices</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="flex items-center justify-between gap-3 text-[13px] text-foreground-light">
Billing portal has 3 open invoices.
<Button size="tiny" onClick={() => setTab('invoices')}>
Show the invoices
</Button>
</TabsContent>
<TabsContent value="invoices" className="text-[13px] text-foreground-light">
INV-2041, INV-2042 and INV-2043 are open.
</TabsContent>
</Tabs>
)
}| State | Controlled | Uncontrolled |
|---|---|---|
| A value | value and onValueChange | defaultValue |
| Open or closed | open and onOpenChange | defaultOpen |
| Checked or not | checked and onCheckedChange | defaultChecked |
| Pressed or not | pressed and onPressedChange | defaultPressed |
ferry-ui has no store. It does not load data, and it does not know your router.
Each component sets a data-slot attribute on its root element and on its important parts. The value is the name of the part: dialog-title, card-footer, field-hint.
Use the attribute to find a part in a test.
const title = document.querySelector('[data-slot="dialog-title"]')Use it also to reach a part from CSS. In this demo, a class on Field aligns the hint to the right.
40 / 160
import * as React from 'react'
import { Field, Textarea } from 'ferry-ui'
const LIMIT = 160
export default function DataSlot() {
const [note, setNote] = React.useState('Send the invoice to the billing contact.')
return (
// Field has no class prop for its hint. The selector finds the hint by its data-slot.
<Field
label="Note on the invoice"
hint={`${note.length} / ${LIMIT}`}
className="w-full max-w-sm [&_[data-slot=field-hint]]:text-right"
>
<Textarea maxLength={LIMIT} value={note} onChange={(event) => setNote(event.target.value)} />
</Field>
)
}| Prop | What the component does |
|---|---|
className | Adds it after its own classes. Your class wins. |
ref | Accepts it as a regular prop. |
| Other props | Puts them on its root element: id, aria-*, data-*. |