Layout
Two levels. Stack, Cluster and Container are the opinionated primitives for arrangements that recur. The flex, grid and spacing utilities are the escape hatch for one-off application layout - reach for a primitive first, drop to utilities when the primitive would need three overrides to say what you meant.
Reference
flex utilities
A Tailwind-shaped subset. Two deliberate differences: the gap scale is the design system's space scale rather than an arbitrary ramp, and there are no responsive or state prefixes - generating those combinatorially is a build tool's job, and this repository has no build tool beyond a file concatenator.
<div class="sk-flex sk-items-center sk-justify-between sk-gap-3">
<span>left</span>
<span>right</span>
</div>| Attribute | Type | Default | Notes |
|---|---|---|---|
sk-flex | sk-flex | sk-inline-flex | - | display |
sk-flex-{dir} | row | row-reverse | col | col-reverse | row | flex-direction |
sk-flex-{wrap} | wrap | wrap-reverse | nowrap | nowrap | flex-wrap |
sk-items-{x} | start | center | end | baseline | stretch | stretch | align-items. sk-items-end is what a button next to a field needs. |
sk-justify-{x} | start | center | end | between | around | evenly | start | justify-content |
sk-content-{x} | start | center | end | between | stretch | - | align-content, for wrapped multi-line flex |
sk-self-{x} | auto | start | center | end | stretch | baseline | - | align-self, set on the child |
sk-gap-{n} | 0-8 | 0 | Maps to --sk-space-{n}. sk-gap-x-{n} and sk-gap-y-{n} set one axis. |
sk-flex-{n} | 1 | auto | initial | none | - | the flex shorthand |
sk-grow, sk-grow-0 | - | - | flex-grow |
sk-shrink, sk-shrink-0 | - | - | flex-shrink |
sk-min-w-0 | - | - | The fix for an overflowing child in a flex row, every time. |
sk-stack
Vertical rhythm.
<div class="sk-stack" data-gap="4">
<div></div>
<div></div>
</div>| Attribute | Type | Default | Notes |
|---|---|---|---|
data-gap | 0-8 | 4 | Space scale index. Omit for the default. |
sk-cluster
A horizontal group that wraps - button rows, tag lists, toolbars.
<div class="sk-cluster" data-gap="3" data-align="end">
<div></div>
<div></div>
</div>| Attribute | Type | Default | Notes |
|---|---|---|---|
data-gap | 0-8 | 3 | Space scale index. |
data-align | "start" | "end" | center | align-items |
data-justify | "center" | "end" | "between" | start | justify-content |
sk-container
Page width.
<div class="sk-container" data-width="lg"></div>| Attribute | Type | Default | Notes |
|---|---|---|---|
data-width | "sm" | "md" | "lg" | "xl" | "lg" | 32 / 48 / 64 / 80rem |
sk-visually-hidden
Keeps content in the accessibility tree but off the screen.
<span class="sk-visually-hidden">Opens in a new window</span>grid utilities
The one arrangement flex genuinely cannot do: a set of equal cards that reflows. A flex row leaves the last item stretched across its own line or a ragged gap at the end; grid with auto-fit goes from four columns to one and keeps every cell the same size, with no media query - which matters here, because this repository cannot generate responsive prefixes. The gap and alignment utilities are not repeated for grid: gap, align-items and justify-content mean the same thing in both, so sk-gap-4 already works.
<div class="sk-grid sk-grid-fit sk-gap-4">
<div class="sk-card">…</div>
</div>| Attribute | Type | Default | Notes |
|---|---|---|---|
sk-grid-cols-{n} | 1-6 | - | minmax(0, 1fr), not 1fr. A track's default minimum is min-content, so one long unbroken string makes its column wider than its share and pushes the grid past its container. |
sk-grid-fit | - | 14rem | auto-fit with a minimum column width. min(…, 100%) on that minimum is load-bearing: without it a 14rem minimum inside a 12rem sidebar overflows instead of reflowing. |
sk-grid-fit-sm / -lg | 9rem / 20rem | - | The same thing at two other minimums, via --sk-grid-min. |
sk-col-span-{n} | 2 | 3 | 4 | 6 | - | Spans, never absolute line numbers - a line number breaks the moment the column count changes, which for a reflowing grid is every resize. |
sk-col-full | - | - | 1 / -1: the full width of whatever the grid currently is. The only span that survives a track count change. |
sk-justify-items-{x} | start | center | end | stretch | stretch | The one alignment axis flex has no equivalent for. sk-justify-self-{x} does the same per item. |
spacing utilities
Margin and padding on the space scale, in logical properties: sk-mt is margin-block-start and sk-ms is margin-inline-start, so a layout flips correctly in a right-to-left document. REACH FOR GAP FIRST - this system zeroes every margin in the reset so that the space between two things belongs to their container, which is what lets a component be dropped into a layout it has never seen. These are for what gap cannot say: padding, centring a fixed-width block, one deliberate exception in an even run, and space around an element whose container you do not control.
<div class="sk-p-4 sk-mb-5">…</div>
<div class="sk-mx-auto">centred</div>| Attribute | Type | Default | Notes |
|---|---|---|---|
sk-p-{n}, sk-px, sk-py | 0-8 | - | padding, padding-inline, padding-block. |
sk-pt / sk-pb / sk-ps / sk-pe | 0-8 | - | block-start, block-end, inline-start, inline-end. |
sk-m-{n} and friends | 0-8 | - | The same seven shapes for margin. |
sk-mx-auto | - | - | The reason margin utilities exist at all in a system built on gap: auto margins are how a fixed-width block centres itself, and no amount of gap on a parent does that job. |
Examples
Reflowing grid
Drag the preview narrow: four columns, then three, two, one. No media query anywhere - the same class does all of it.
Kiwi Kart
Eight tracks, four islands, one very determined bird.
Fern & Forge
Build a workshop, fill it with apprentices, never finish anything.
Tui Trader
A trading sim about being slightly too honest.
Moa Mountain
Climb it. That is the whole game.
Pounamu
A puzzle game carved out of one rock.
Show markup
<div class="sk-grid sk-grid-fit sk-gap-4">
<div class="sk-card">
<div class="sk-card-body">
<div class="sk-flex sk-flex-col sk-gap-2">
<h3 class="sk-card-title">
Kiwi Kart
</h3>
<p class="sk-card-subtitle">
Eight tracks, four islands, one very determined bird.
</p>
</div>
</div>
</div>
<div class="sk-card">
<div class="sk-card-body">
<div class="sk-flex sk-flex-col sk-gap-2">
<h3 class="sk-card-title">
Fern & Forge
</h3>
<p class="sk-card-subtitle">
Build a workshop, fill it with apprentices, never finish anything.
</p>
</div>
</div>
</div>
<div class="sk-card">
<div class="sk-card-body">
<div class="sk-flex sk-flex-col sk-gap-2">
<h3 class="sk-card-title">
Tui Trader
</h3>
<p class="sk-card-subtitle">
A trading sim about being slightly too honest.
</p>
</div>
</div>
</div>
<div class="sk-card">
<div class="sk-card-body">
<div class="sk-flex sk-flex-col sk-gap-2">
<h3 class="sk-card-title">
Moa Mountain
</h3>
<p class="sk-card-subtitle">
Climb it. That is the whole game.
</p>
</div>
</div>
</div>
<div class="sk-card">
<div class="sk-card-body">
<div class="sk-flex sk-flex-col sk-gap-2">
<h3 class="sk-card-title">
Pounamu
</h3>
<p class="sk-card-subtitle">
A puzzle game carved out of one rock.
</p>
</div>
</div>
</div>
</div>Fixed columns and spans
sk-col-full is 1 / -1, so the banner cell stays full width whatever the column count is.
Show markup
<div class="sk-grid sk-grid-cols-3 sk-gap-3">
<div class="sink-grid-cell sk-col-full">
sk-col-full
</div>
<div class="sink-grid-cell sk-col-span-2">
sk-col-span-2
</div>
<div class="sink-grid-cell">
1
</div>
<div class="sink-grid-cell">
2
</div>
<div class="sink-grid-cell">
3
</div>
<div class="sink-grid-cell">
4
</div>
</div>Flex utilities
The common arrangements, written as utilities.
sk-flex sk-gap-2sk-flex sk-gap-2 sk-justify-centersk-flex sk-gap-2 sk-justify-betweensk-flex sk-gap-2 sk-flex-row-reversesk-flex sk-flex-col sk-gap-2sk-items-* with mixed heightsShow markup
<div class="sk-flex sk-flex-col sk-gap-5">
<div class="sk-flex sk-flex-col sk-gap-1">
<code class="sink-caption">
sk-flex sk-gap-2
</code>
<div class="sk-flex sk-gap-2">
<div class="sink-demo-box">
one
</div>
<div class="sink-demo-box">
two
</div>
<div class="sink-demo-box">
three
</div>
</div>
</div>
<div class="sk-flex sk-flex-col sk-gap-1">
<code class="sink-caption">
sk-flex sk-gap-2 sk-justify-center
</code>
<div class="sk-flex sk-gap-2 sk-justify-center">
<div class="sink-demo-box">
one
</div>
<div class="sink-demo-box">
two
</div>
<div class="sink-demo-box">
three
</div>
</div>
</div>
<div class="sk-flex sk-flex-col sk-gap-1">
<code class="sink-caption">
sk-flex sk-gap-2 sk-justify-between
</code>
<div class="sk-flex sk-gap-2 sk-justify-between">
<div class="sink-demo-box">
one
</div>
<div class="sink-demo-box">
two
</div>
<div class="sink-demo-box">
three
</div>
</div>
</div>
<div class="sk-flex sk-flex-col sk-gap-1">
<code class="sink-caption">
sk-flex sk-gap-2 sk-flex-row-reverse
</code>
<div class="sk-flex sk-gap-2 sk-flex-row-reverse">
<div class="sink-demo-box">
one
</div>
<div class="sink-demo-box">
two
</div>
<div class="sink-demo-box">
three
</div>
</div>
</div>
<div class="sk-flex sk-flex-col sk-gap-1">
<code class="sink-caption">
sk-flex sk-flex-col sk-gap-2
</code>
<div class="sk-flex sk-flex-col sk-gap-2">
<div class="sink-demo-box">
one
</div>
<div class="sink-demo-box">
two
</div>
<div class="sink-demo-box">
three
</div>
</div>
</div>
<div class="sk-flex sk-flex-col sk-gap-1">
<code class="sink-caption">
sk-items-* with mixed heights
</code>
<div class="sk-flex sk-flex-wrap sk-gap-4">
<div class="sk-flex sk-gap-2 sink-align-demo sk-items-start">
<div class="sink-demo-box">
start
</div>
<div class="sink-demo-box sink-demo-tall">
tall
</div>
</div>
<div class="sk-flex sk-gap-2 sink-align-demo sk-items-center">
<div class="sink-demo-box">
center
</div>
<div class="sink-demo-box sink-demo-tall">
tall
</div>
</div>
<div class="sk-flex sk-gap-2 sink-align-demo sk-items-end">
<div class="sink-demo-box">
end
</div>
<div class="sink-demo-box sink-demo-tall">
tall
</div>
</div>
<div class="sk-flex sk-gap-2 sink-align-demo sk-items-baseline">
<div class="sink-demo-box">
baseline
</div>
<div class="sink-demo-box sink-demo-tall">
tall
</div>
</div>
<div class="sk-flex sk-gap-2 sink-align-demo sk-items-stretch">
<div class="sink-demo-box">
stretch
</div>
<div class="sink-demo-box sink-demo-tall">
tall
</div>
</div>
</div>
</div>
</div>Aligning a control row
The case worth knowing. A field is a column of label plus control, so a plain row containing one is as tall as both; stretch and centre alignment then put the button somewhere you did not mean. sk-items-end fixes it. sk-input-group avoids the question entirely by putting the button inside the field, below the label, where it is aligned by construction.
sk-flex sk-gap-3 - the row is as tall as the field, so the button stretchessk-flex sk-items-end sk-gap-3 - correct, if you remember itsk-input-group - correct by construction, nothing to rememberShow markup
<div class="sk-flex sk-flex-col sk-gap-6">
<div class="sk-flex sk-flex-col sk-gap-2">
<code class="sink-caption">
sk-flex sk-gap-3 - the row is as tall as the field, so the button stretches
</code>
<div class="sk-flex sk-gap-3">
<div class="sk-field">
<label class="sk-label" for="align-a">
Email
</label>
<input class="sk-input" id="align-a" name="align-a" type="email" value="" placeholder="you@example.com" data-size="md" aria-invalid="false">
</div>
<button class="sk-button" type="button" data-variant="solid" data-size="md">
Subscribe
</button>
</div>
</div>
<div class="sk-flex sk-flex-col sk-gap-2">
<code class="sink-caption">
sk-flex sk-items-end sk-gap-3 - correct, if you remember it
</code>
<div class="sk-flex sk-items-end sk-gap-3">
<div class="sk-field">
<label class="sk-label" for="align-b">
Email
</label>
<input class="sk-input" id="align-b" name="align-b" type="email" value="" placeholder="you@example.com" data-size="md" aria-invalid="false">
</div>
<button class="sk-button" type="button" data-variant="solid" data-size="md">
Subscribe
</button>
</div>
</div>
<div class="sk-flex sk-flex-col sk-gap-2">
<code class="sink-caption">
sk-input-group - correct by construction, nothing to remember
</code>
<div class="sk-field">
<label class="sk-label" for="signup">
Email
</label>
<div class="sk-input-group">
<input class="sk-input" id="signup" name="signup" type="email" value="" placeholder="you@example.com" data-size="md" aria-invalid="false">
<button class="sk-button" type="button" data-variant="solid" data-size="md">
Subscribe
</button>
</div>
</div>
</div>
</div>Grow and shrink
sk-min-w-0 is the one people forget: a flex item will not shrink below its content width without it, so one long unbroken string blows the whole row out.
Show markup
<div class="sk-flex sk-flex-col sk-gap-4">
<div class="sk-flex sk-gap-2">
<div class="sink-demo-box sk-flex-1">
sk-flex-1
</div>
<div class="sink-demo-box">
auto
</div>
</div>
<div class="sk-flex sk-gap-2">
<div class="sink-demo-box">
auto
</div>
<div class="sink-demo-box sk-grow">
sk-grow
</div>
<div class="sink-demo-box">
auto
</div>
</div>
<div class="sk-flex sk-gap-2">
<div class="sink-demo-box sk-flex-1 sk-min-w-0 sink-truncate">
sk-min-w-0 stops this blowing the row out: averyveryverylongunbrokenstringindeed
</div>
<div class="sink-demo-box sk-shrink-0">
sk-shrink-0
</div>
</div>
</div>Stack
Gap is set with data-gap rather than an inline style, so the same markup works unchanged from templ, Svelte or hand-written HTML - and no binding has to emit a style attribute that a strict CSP would block.
data-gap=1data-gap=3data-gap=5Show markup
<div class="sk-stack" data-gap="4">
<div class="sk-stack" data-gap="2">
<code class="sink-row-label">
data-gap=1
</code>
<div class="sk-stack" data-gap="1">
<div class="sink-bar" data-token="space-8">
</div>
<div class="sink-bar" data-token="space-8">
</div>
<div class="sink-bar" data-token="space-8">
</div>
</div>
</div>
<div class="sk-stack" data-gap="2">
<code class="sink-row-label">
data-gap=3
</code>
<div class="sk-stack" data-gap="3">
<div class="sink-bar" data-token="space-8">
</div>
<div class="sink-bar" data-token="space-8">
</div>
<div class="sink-bar" data-token="space-8">
</div>
</div>
</div>
<div class="sk-stack" data-gap="2">
<code class="sink-row-label">
data-gap=5
</code>
<div class="sk-stack" data-gap="5">
<div class="sink-bar" data-token="space-8">
</div>
<div class="sink-bar" data-token="space-8">
</div>
<div class="sink-bar" data-token="space-8">
</div>
</div>
</div>
</div>Cluster
Wraps by default. Drag the preview narrower to watch it reflow.
Show markup
<div class="sk-cluster" data-gap="2">
<button class="sk-button" type="button" data-variant="neutral" data-size="sm">
One
</button>
<button class="sk-button" type="button" data-variant="neutral" data-size="sm">
Two
</button>
<button class="sk-button" type="button" data-variant="neutral" data-size="sm">
Three
</button>
<button class="sk-button" type="button" data-variant="neutral" data-size="sm">
Four
</button>
<button class="sk-button" type="button" data-variant="neutral" data-size="sm">
Five
</button>
<button class="sk-button" type="button" data-variant="neutral" data-size="sm">
A longer label
</button>
<button class="sk-button" type="button" data-variant="neutral" data-size="sm">
And another one
</button>
</div>Container widths
data-width=smdata-width=mddata-width=lgdata-width=xlShow markup
<div class="sk-stack" data-gap="3">
<div class="sk-container" data-width="sm">
<div class="sink-demo-box">
<code>
data-width=sm
</code>
</div>
</div>
<div class="sk-container" data-width="md">
<div class="sink-demo-box">
<code>
data-width=md
</code>
</div>
</div>
<div class="sk-container" data-width="lg">
<div class="sink-demo-box">
<code>
data-width=lg
</code>
</div>
</div>
<div class="sk-container" data-width="xl">
<div class="sink-demo-box">
<code>
data-width=xl
</code>
</div>
</div>
</div>Cascade layers
Everything in sk.css lives inside @layer. The kitchen sink's own stylesheet is unlayered, so it wins automatically - no !important anywhere in the repository. That is the property that makes the bundle safe to drop into an app that already has CSS.
The stylesheet declares its order up front:
@layer sk.reset, sk.tokens, sk.base, sk.components, sk.utilities;Unlayered rules in a host application beat every one of those layers regardless of specificity, so overriding a component never needs an escalation war. The sidebar and preview frames around this page are styled exactly that way.
Show markup
<div>
<p>
The stylesheet declares its order up front:
</p>
<pre>
<code>
@layer sk.reset, sk.tokens, sk.base, sk.components, sk.utilities;
</code>
</pre>
<p>
Unlayered rules in a host application beat every one of those layers regardless of specificity, so overriding a component never needs an escalation war. The sidebar and preview frames around this page are styled exactly that way.
</p>
</div>