Reference
LandlordKit design system
Civic precision: a compliance tool built to read like a well-made document. Paper surfaces, a single confident brand hue, urgency as a first-class colour axis, and tabular figures everywhere a date or a dollar appears.
Surfaces and brand
Warm paper ground, pure white cards raised on top, deep teal-blue for action.
Sunken
--surface-sunken
Background
--background
Card
--card
Muted
--muted
Accent
--accent
Input border
--input
Primary
--primary
Foreground
--foreground
Urgency tones
Five tones, one meaning each. Every component that shows urgency reads its colour from the same map, so an overdue deadline looks identical everywhere.
| Tone | Ramp | When to use it | Example |
|---|---|---|---|
| Critical | A deadline has passed, or data is wrong in a way that invalidates a notice. | Standard lease overdue | |
| Warning | A deadline is close enough to plan around, or a value is unconfirmed and will block a notice later. | Rent control status unknown | |
| Information | Neutral compliance information: an upcoming date, or which rule applied and why. | 14-day notice period, in effect until September 20, 2026 | |
| Satisfied | A requirement is satisfied on time, or a window is now open. | L1 filing opens | |
| Neutral | Structural or archival. No urgency implied. | Tenancy ended |
Typography
Source Serif 4 for display and headings, Inter for everything functional. Numbers are always tabular so columns line up.
Layout width
Two variants, chosen by route in the app shell rather than page by page. No page sets its own max-width.
data
max-w-[90rem]
Tables and portfolio surfaces: the calendar, the lists, a property or unit page.
measured
max-w-3xl
Reading and form surfaces: a notice, settings, onboarding, every create and edit form.
The route-to-variant mapping is data in lib/page-width.ts and is covered by unit tests, so a new page picks a side deliberately. The measured column is centred in the content area, not pinned to the left edge against the sidebar.
Waiting
Every route in the product is a Server Component doing real database work, so there is always a gap between the click and the answer. Two shapes cover it, chosen by the same rule that decides the page's width.
A data surface
The dashboard, the calendar, a list, a detail page.
A measured surface
Settings, onboarding, and every create, edit and prepare screen.
A skeleton here is a SHAPE and never a mock. Reproducing a table’s exact columns is wrong more often than right, because the column count depends on data nobody has loaded, and a fallback that changes shape as it resolves is worse than one that never claimed to match. The pulse is decoration: prefers-reduced-motion stops it and the meaning survives in the layout, with role="status" announcing it once for the page rather than once per bar. Which route gets which shape is derived from page-width.ts by a test, not kept by hand.
The accessibility floor
Four rules the whole system is built on, and none of them is a matter of taste. Three are enforced by tests; the fourth is a stylesheet rule nothing can opt out of by accident.
One focus treatment, and it is an outline
Full strength, 2px, offset by 2px. Tab into these and every one looks the same.
An outline rather than a ring, for three reasons. It needs no offset colour, so one declaration is right on paper, on a white card and on a tinted callout alike. It is drawn at full strength, so it measures 6.3:1 in light and 8.9:1 in dark. And it survives forced-colors mode, where a box shadow is discarded outright. Components no longer set outline-none, and a test fails the build if one starts.
Contrast is measured, not judged
lib/contrast.ts parses globals.css and converts each oklch value to sRGB; contrast.test.ts asserts every pair in both themes.
- 4.5:1Body and secondary textWCAG 1.4.3. Includes muted text at text-xs, which is where the disclaimer and every table label live.
- 3:1The border of a form controlWCAG 1.4.11. Was 1.37:1, which is a box whose edge you infer from its shadow.
- 3:1The focus outlineWCAG 2.4.11. Was 2.27:1: the token was fine and every component painted it at half opacity.
- 3:1A tone used as a markStatus dots and the accent rule down a stat tile. Amber was 2.55:1 on a white card.
Motion is a preference
prefers-reduced-motion switches off every transition and animation in the product.
Nothing here is load bearing. Every animation is decoration on a state change that is also carried by layout, colour or text, so switching them off costs nothing at all. That is the property to preserve: if a new component ever needs its animation to be understood, it needs a second cue rather than an exemption.
Bypass and reach
A skip link on every shell, and one keyboard route to anything.
Every page in the app puts fourteen navigation links before its content, which is what WCAG 2.4.1 is about. A skip link is the first focusable element in the document on all three shells, and Ctrl K opens the quick jump over every page, property, unit and tenancy. Both are visible controls rather than folklore: the skip link appears on focus, and the jump box is drawn in the sidebar with its shortcut printed on it.
Components
The working set.
Buttons
Primary for the one action that moves the task forward.
Status badges
Tone plus a dot, so meaning survives without colour.
Callouts
Home for the engine's plain-English explanations. These sentences are the product, so they never get buried as grey helper text.
Why this date
The earliest lawful increase is September 1, 2026: 90 days after service and at least 12 months after the last lawful increase on August 1, 2025.
Rent control status not confirmed
Rent increase notices are blocked until the status is set. Units first occupied on or before November 15, 2018 are rent controlled.
This could not be saved
The effective date is only 62 days after service. It must be at least 90.
Stat tiles
Tone drives an accent rule down the left edge rather than a full tint. At dashboard scale, tinted tiles turn into noise.
Active tenancies
12
Across 4 properties
Needs attention
2
Deadlines already passed.
Due in 30 days
5
Arriving this month.
Monthly rent roll
$21,340.00
Active tenancies.
Stat tiles as filters
A count on a dashboard is really a question, and the tile is the nearest place to answer it. Give a tile onSelect and it becomes the filter for its own number: a button, aria-pressed, ringed when it is the one applied. A tile reading zero should not be selectable.
Showing 10 of 10 rows.
Data table
The default for any list of records. Type in the filter, click a header to sort, click a row to open it, open a row's explanation with the chevron, and use the button without opening anything. Narrow the window until this table has less than the 56rem it measures and it becomes the card stack below. That is a container query, not a viewport one: the sidebar appears at the width a viewport query would switch on, so a table at 1024px had only 700px to fit into.
- Current rent
- $2,140.00
- Earliest increase
- 3 days ago
- Maximum without LTB approval
- $2,184.94 (+$44.94, 2.1%)
Notice pending- Current rent
- $1,450.00
- Earliest increase
- in 4 weeks
- Maximum without LTB approval
- No guideline cap (exempt unit, Form N2)
- Current rent
- $1,895.00
- Earliest increase
- in 3 months
- Maximum without LTB approval
- $1,934.80 (+$39.80, 2.1%)
Data table, small screens
Once the table runs out of room every row becomes its own card: the title column is the heading, the rest become label and value pairs, and the row action and its explanation stay on screen instead of sitting in a fifth column off the right edge. Each card lays itself out from its own width, so this is the real 390px rendering, not an approximation of it.
- Current rent
- $2,140.00
- Earliest increase
- 3 days ago
- Maximum without LTB approval
- $2,184.94 (+$44.94, 2.1%)
Notice pending- Current rent
- $1,450.00
- Earliest increase
- in 4 weeks
- Maximum without LTB approval
- No guideline cap (exempt unit, Form N2)
- Current rent
- $1,895.00
- Earliest increase
- in 3 months
- Maximum without LTB approval
- $1,934.80 (+$39.80, 2.1%)
Data table facets
A text filter finds rows that mention something; a facet selects rows that ARE something. Typing the word served into the box also matches not served, so a status or a form type gets a drop-down and an exact match instead. Facets compare the row's plain value, never the rendered label, and combine with each other and with the filter box. A facet does not need a column of its own: this one narrows by rent control status, which the table only shows inside a sentence.
- Current rent
- $1,895.00
- Earliest increase
- in 3 months
- Maximum without LTB approval
- $1,934.80 (+$39.80, 2.1%)
- Current rent
- $1,450.00
- Earliest increase
- in 4 weeks
- Maximum without LTB approval
- No guideline cap (exempt unit, Form N2)
- Current rent
- $2,140.00
- Earliest increase
- 3 days ago
- Maximum without LTB approval
- $2,184.94 (+$44.94, 2.1%)
Notice pending
Data table column contract
Cells are React nodes, so nothing in the component can read them. Each row carries a parallel values record of plain data, and that is what sorts and filters.
- card: title
- The card heading, and the row's link target. First column by default.
- card: meta
- A label and value line in the card body. The default for everything else.
- card: action
- Pinned to the foot of the card, above the row link so it stays clickable.
- card: hidden
- Table only. For a column the card already says another way.
- sortable, filterable
- Opt in per column. Sorting reads values, never the rendered cell, so a column can show “in 3 months” and sort by the real date.
- row href, row tone
- Makes the whole row a navigation target, and draws the left accent rule from the shared tone map.
- minTableWidth
- How much room the columns need before a table is the better shape, measured rather than guessed: open the page wide, read the table's scrollWidth, and pick the next size up. Below it the rows become cards. Guessing low puts the row action back off the right edge, which is the one thing this component exists to prevent. Check the tier against what the page can offer, too: a measured route gives a table inside a card about 41rem at any screen width, so anything from 3xl up would leave it cards at 1440px, which is a threshold nothing can reach rather than a measurement. Use xl or 2xl there.
- facets
- Exact-match drop-downs beside the filter box, for the questions a search box answers wrongly. Each names a column key and a list of option values, and those values have to be the stable ones in the row's values record rather than the sentence in the cell.
- row expansion
- A second layer per row: a disclosure at the end of the row holding the engine's full explanation in a Callout, spanning the whole table width. The scannable layer is a one line summary in a cell; the explanation stays one click away rather than being printed at table density or demoted to grey helper text.
Table primitives
Underneath DataTable: a tinted header rail, roomy rows, and a left accent rule carrying urgency without tinting the row. Reach for these directly only for a table that is not a list of records.
| Date | What | Where |
|---|---|---|
3 weeks ago | Standard lease overdue | 72 Victoria St S, Unit 2 |
in 9 days | N4 termination | 18 Allen St E, Unit 1 |
Card and description list
The detail-page workhorse.
- Current rent
- $1,895.00
- Last lawful increase
- August 1, 2025
- Earliest lawful increase
- September 1, 2026
Empty state
Always a title plus a next action, never a bare sentence.
No properties yet
Add your first building, then its units and tenancies.
Danger zone
Destructive actions are quarantined with the consequence spelled out. Delete never sits next to Save.
Danger zone
Delete is for data entry mistakes only. Records with notice history are protected.
Legal disclaimer
Non-negotiable on every compliance output. The wording comes from the engine, so it cannot drift page to page.
This is general information and document preparation assistance, not legal advice. Confirm requirements with the Landlord and Tenant Board (LTB) or a licensed paralegal or lawyer before acting.
Page header
Back link, eyebrow, serif title, description, actions.
72 Victoria St S, Unit 2
Notice of Rent Increase
Filled onto the official LTB Form N1, v. 01/04/2022.