Styles are values. Pleat folds them.
36 elements and two conditions. No JavaScript moves it. Run your pointer across it.
Merge, nest, and vary styles by Model, with laws that hold. Pleat compiles every rule to atomic CSS before the first render, so your views only choose among them.
pnpm add @pleat/core @pleat/foldkitA style is data, so everything about it is known before the first render. A recipe call is a few map lookups. A server-rendered page ships only the CSS it uses. And a language model building your interface can choose styles, but never write CSS.
What Pleat makes of a style
const card = Style.make({
display: 'grid',
gap: 12,
padding: 20,
borderRadius: 12,
}).pipe(
Style.when(When.hover, { translate: '0 -2px' }),
Style.when(When.minWidth('40rem'), { padding: 28 }),
)| always | borderRadius | 12px |
| always | gap | 12px |
| always | padding | 20px |
| always | display | grid |
| @media (min-width: 40rem) | padding | 28px |
| :hover | translate | 0 -2px |
css(card) → class="p0k3q87ls p888beiv5 pb0zfsz83 pfoawaduf pi9mxwotb pnimzyilf"
- 90 ns
- to resolve a recipe to its class names. cva takes 228.
- 0 rules
- created during render. Each exists once its module loads.
- 1 class
- per declaration, shared by every style that uses it.
- 4 laws
- checked by property tests, and in Chromium against the cascade.
How it works
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Recipe, Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'
const card = Style.make({
display: 'grid',
gap: 12,
padding: 20,
borderRadius: 12,
}).pipe(
Style.when(When.hover, {
boxShadow: '0 8px 24px -12px rgb(0 0 0 / 0.3)',
}),
)
const badge = Recipe.make({
variants: {
status: {
Draft: { color: 'oklch(50% 0.02 60)' },
Live: { color: 'oklch(48% 0.12 150)' },
},
},
})
type Post = Readonly<{ title: string; status: 'Draft' | 'Live' }>
export const postView = <Message>(
post: Post,
h: HtmlBuilder<Message>,
): Html =>
h.article(
[...css(card)],
[
h.h2([], [post.title]),
h.span(
[...css(badge({ status: post.status }))],
[post.status],
),
],
)- 1
Define
Style.maketurns declarations into atoms: one condition, one property, one value, one class.cardand everybadgeoption exist as rules the moment this module loads. - 2
Choose
In the view,
badge({ status })picks a finished style with a few map lookups, andcss()hands Foldkit oneClassattribute it has already built. Nothing about CSS happens during render. - 3
Ship
On the server,
renderDocumentputs the rules a page uses in its<head>. The client computes the same class names, so hydration matches.
What you get
- Lawful
- Merging is associative, has an identity, and changes nothing when repeated. Property tests check the laws, and a Chromium test checks that the cascade agrees with them.
- Built for Foldkit
- One
css()spread per element. Conditions match@foldkit/uistates likedata-openanddata-disabled. Variant props are Schemas, like your Model. - Fast on both sides
- Every rule exists before the first render. A recipe resolves in about 90 ns. Each server-rendered page ships only the CSS it uses.
- Safe to generate
- Recipes and themes export JSON Schema. A model chooses among styles that are already compiled, so it never writes CSS.
Try a recipe
This is the button this site uses. Each choice below is a Message; the view calls button(props) and gets back a style that was compiled when the page loaded. The JSON Schema is what a model would see.
class="p2tlpfw8s p3jj6i9ri p5gntyr7x p5lxiaxwz p60vdf8fb p8qkuob75 pabrozuyb pad9fipdp padokx33q pcj4oq0wl pdzc25iv0 pe9hfpdsn pea217ejl phyrwmuqj pjraj5zfj pkblbygws pkrx23e1u ptlo9grqd pul02j3ge pumfxn5np pv997rctw pviw4fpc5 pym8g06rt pzbsj47wi pzrj6e28q"
| condition | property | value |
|---|---|---|
| always | border | 1px solid transparent |
| always | gap | var(--space-2) |
| always | paddingBlock | 0.6875rem |
| always | borderRadius | var(--radius-md) |
| always | textDecoration | none |
| always | paddingInline | var(--space-4) |
| always | fontWeight | 400 |
| always | cursor | pointer |
| always | justifyContent | center |
| always | display | inline-flex |
| always | whiteSpace | nowrap |
| always | fontSize | 1rem |
| always | backgroundColor | var(--color-ink) |
| always | fontFamily | var(--font-sans) |
| always | color | var(--color-canvas) |
| always | lineHeight | 1 |
| always | alignItems | center |
| @media (prefers-reduced-motion: no-preference) | transition | background-color 140ms ease, border-color 140ms ease, color 140ms ease, transform 140ms ease |
| :hover | backgroundColor | color-mix(in oklab, var(--color-ink), var(--color-accent) 30%) |
| :focus-visible | outline | 2px solid var(--color-accent) |
| :focus-visible | outlineOffset | 2px |
| :active | transform | translateY(1px) |
| :disabled, [data-disabled], [aria-disabled="true"] | opacity | 0.5 |
| :disabled, [data-disabled], [aria-disabled="true"] | transform | none |
| :disabled, [data-disabled], [aria-disabled="true"] | cursor | not-allowed |
{
"type": "object",
"properties": {
"tone": {
"type": "string",
"enum": [
"Primary",
"Neutral",
"Quiet"
],
"description": "Primary for the main action, Neutral for others, Quiet for low emphasis."
},
"size": {
"type": "string",
"enum": [
"Small",
"Medium",
"Large"
],
"description": "The button’s padding and type size."
},
"isPending": {
"type": "boolean",
"description": "Whether the button’s action is in progress."
}
},
"additionalProperties": false,
"title": "Button",
"description": "A button or a link styled as one."
}Change styles with functions, not overrides
A transform is a function from style to style. Style.evolve maps values where they are declared, and Calc and Color return CSS functions over your tokens, so a transform written once holds under every theme.
Recipes take finished styles as branches. Props pick a branch; they never build one. So every rule a recipe can produce is known when its module loads, which is what makes extraction, server rendering, and generated interfaces possible. The algebra page has the details.
import { Option } from 'effect'
import { Calc, Color, Recipe, Style } from '@pleat/core'
// Transforms are functions from style to style. They compile to
// CSS functions, so they hold under every theme.
const bumpFontSize = Style.evolve({
fontSize: size => Calc.add(size, 4),
})
const darkenText = Style.evolve({
color: color => Color.darken(color, 0.1),
})
const withBrandFont = Style.merge(
Style.make({ fontFamily: '"Instrument Serif", serif' }),
)
const raised = Style.make({
boxShadow: '0 2px 3px rgb(0 0 0 / 0.25)',
})
// An outline derived from a finished style. Props never reach it;
// they only pick which finished branch applies.
const outlined = (style: Style.Style) =>
Style.make({
border: '1px solid currentColor',
backgroundColor: 'transparent',
color: Option.getOrElse(
Style.get(style, 'backgroundColor'),
() => 'inherit',
),
})
const primary = Style.make({ backgroundColor: 'green' })
const secondary = Style.make({ backgroundColor: 'blue' })
export const myButton = Recipe.make({
base: Style.make({
fontSize: 16,
fontWeight: 'bold',
color: 'white',
}).pipe(
bumpFontSize,
darkenText,
withBrandFont,
Style.merge(raised),
),
variants: {
isPrimary: { true: primary, false: secondary },
isOutline: { true: {}, false: {} },
},
defaults: { isPrimary: false, isOutline: false },
compounds: [
{
when: { isPrimary: true, isOutline: true },
style: outlined(primary),
},
{
when: { isPrimary: false, isOutline: true },
style: outlined(secondary),
},
],
})
myButton({ isPrimary: false, isOutline: true })Pleat is the styling layer of the FACE stack: Foldkit, Alchemy, Cloudflare, Effect.
This site is one: a Foldkit app rendered to static pages, styled only with Pleat, and deployed to Cloudflare by an Alchemy stack.