Skip to content

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/foldkit

A 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

1A value you write
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 }),
)
2Atoms, compiled at module load
alwaysborderRadius12px
alwaysgap12px
alwayspadding20px
alwaysdisplaygrid
@media (min-width: 40rem)padding28px
:hovertranslate0 -2px
3One attribute for the view

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

post.tsTypeScript
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. 1

    Define

    Style.make turns declarations into atoms: one condition, one property, one value, one class. card and every badge option exist as rules the moment this module loads.

  2. 2

    Choose

    In the view, badge({ status }) picks a finished style with a few map lookups, and css() hands Foldkit one Class attribute it has already built. Nothing about CSS happens during render.

  3. 3

    Ship

    On the server, renderDocument puts 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/ui states like data-open and data-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.

tone
size
isPending
Live
button({"tone":"Primary","size":"Medium","isPending":false})

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"

conditionpropertyvalue
alwaysborder1px solid transparent
alwaysgapvar(--space-2)
alwayspaddingBlock0.6875rem
alwaysborderRadiusvar(--radius-md)
alwaystextDecorationnone
alwayspaddingInlinevar(--space-4)
alwaysfontWeight400
alwayscursorpointer
alwaysjustifyContentcenter
alwaysdisplayinline-flex
alwayswhiteSpacenowrap
alwaysfontSize1rem
alwaysbackgroundColorvar(--color-ink)
alwaysfontFamilyvar(--font-sans)
alwayscolorvar(--color-canvas)
alwayslineHeight1
alwaysalignItemscenter
@media (prefers-reduced-motion: no-preference)transitionbackground-color 140ms ease, border-color 140ms ease, color 140ms ease, transform 140ms ease
:hoverbackgroundColorcolor-mix(in oklab, var(--color-ink), var(--color-accent) 30%)
:focus-visibleoutline2px solid var(--color-accent)
:focus-visibleoutlineOffset2px
:activetransformtranslateY(1px)
:disabled, [data-disabled], [aria-disabled="true"]opacity0.5
:disabled, [data-disabled], [aria-disabled="true"]transformnone
:disabled, [data-disabled], [aria-disabled="true"]cursornot-allowed
Recipe.jsonSchema(button)
{
  "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.

button.tsTypeScript
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.