Skip to content
Guide

Style a Foldkit app

Install two packages, define tokens, write styles, and use them in views. Server rendering and static generation need one line in the server entry.

Install

pnpm add @pleat/core @pleat/foldkit

@pleat/core has the algebra and depends only on Effect. @pleat/foldkit connects it to Foldkit views and to Foldkit’s server rendering. Both expect Effect v4; there is no build plugin to configure.

Tokens and themes

Tokens are named custom properties with a kind (color, length, fontFamily…). A theme gives every token a value, checked against its kind. Applying a theme to a selector sets the properties there, so switching themes is an attribute in your view and costs no re-render of styles.

tokens.tsTypeScript
import { Global, Theme, Token, When } from '@pleat/core'

export const tokens = Token.make({
  color: {
    canvas: Token.color,
    ink: Token.color,
    accent: Token.color,
  },
  space: { 2: Token.length, 4: Token.length },
})

export const light = Theme.make(tokens, {
  color: {
    canvas: 'oklch(98% 0.01 80)',
    ink: 'oklch(24% 0.02 50)',
    accent: 'oklch(52% 0.16 34)',
  },
  space: { 2: '0.5rem', 4: '1rem' },
})

export const dark = Theme.extend(light, tokens, {
  color: {
    canvas: 'oklch(18% 0.02 272)',
    ink: 'oklch(93% 0.01 80)',
  },
})

// Light everywhere, dark when the Model says so or the system asks
// for it.
Global.theme(light)
Global.theme(dark, { selector: ':root:has([data-theme="Dark"])' })
Global.theme(dark, {
  selector: ':root:has([data-theme="System"])',
  when: When.dark,
})

Put the theme choice in your Model and render it as data-theme on your root element. This site does exactly that with the switch in the header.

Styles

A style is a value. Style.make takes declarations, Style.when adds declarations under a condition, and Style.merge applies one style after another. Define styles where the module loads, not inside views: that is when their rules are created.

styles.tsTypeScript
import { Calc, Color, Style, When } from '@pleat/core'

import { tokens } from './tokens.ts'

const { color, space } = tokens

// A style is a value. Later declarations win, like object spread.
export const link = Style.make({
  color: color.ink,
  textUnderlineOffset: '0.2em',
}).pipe(
  Style.when(When.hover, { color: color.accent }),
  Style.when(When.focusVisible, {
    outline: `2px solid ${color.accent}`,
  }),
)

// Inside a hovered element marked `row`, and for @foldkit/ui's
// data-selected state.
export const row = When.marker('row')
export const rowLabel = Style.make({ color: color.ink }).pipe(
  Style.when(When.within(row, When.hover), { color: color.accent }),
  Style.when(When.selected, { fontWeight: 600 }),
)

// Transforms run once, where the style is defined, and compile to
// CSS functions, so they still hold after a theme changes the
// tokens underneath them.
const bumpFontSize = Style.evolve({
  fontSize: size => Calc.add(size, 2),
})
const darkenText = Style.evolve({
  color: value => Color.darken(value, 0.08),
})

export const caption = Style.make({
  fontSize: 13,
  color: color.accent,
  padding: space[2],
}).pipe(bumpFontSize, darkenText)
  • Conditions include When.hover, When.focusVisible, When.active, When.dark, When.minWidth(...), When.container(...), and the states @foldkit/ui sets: When.open, When.selected, When.disabled, When.highlighted, When.checked, When.invalid.
  • When two conditions hold at once and set the same property, the stronger one wins: environment < ancestors < element state < interaction < disabled and invalid. When.all(a, b) outranks both a and b. The algebra page has the full order.
  • Rules live in the pleat cascade layer, so anything you write outside a layer, Tailwind included, wins over Pleat.

Use styles in views

Spread css(...) into an element’s attributes. It merges every style you pass into one Class attribute and every variable binding into one Style attribute. Pass everything to one call: Foldkit keeps only the last Class an element is given.

save.tsTypeScript
import type { Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { defineRouteUnion, literal, mapTo } from 'foldkit/route'

import { Button } from '@foldkit/ui'
import { Style } from '@pleat/core'
import { css } from '@pleat/foldkit'

import { button } from './recipe.ts'
import { link } from './styles.ts'

type Model = Readonly<{ isSaving: boolean; isPrimary: boolean }>
const Message = defineMessageUnion({ ClickedSave: {} })
type Message = typeof Message.Type

const ring = Style.make({ outline: '2px solid currentColor' })

export const saveView = (
  model: Model,
  h: HtmlBuilder<Message>,
): Html =>
  Button.view(
    {
      onClick: Message.ClickedSave(),
      isDisabled: model.isSaving,
      toView: attributes =>
        h.button(
          [
            ...attributes.button,
            // One css() call per element: Foldkit keeps only the
            // last Class.
            ...css(
              button({
                tone: model.isPrimary ? 'Primary' : 'Neutral',
                isPending: model.isSaving,
              }),
              model.isPrimary ? ring : Style.empty,
            ),
          ],
          ['Save'],
        ),
    },
    h,
  )

const AppRoute = defineRouteUnion({ About: {} })
const aboutRouter = mapTo(AppRoute.About)(literal('about'))

export const footerLink = (h: HtmlBuilder<Message>): Html =>
  h.a([h.Href(aboutRouter()), ...css(link)], ['About'])

css caches its result per style, so a view that renders the same styles again allocates nothing for them. The first time a style is used in a browser, its rules are inserted at their sorted position in a constructed stylesheet.

Variants with recipes

A recipe is a finite family of styles. Its variant props are typed by an Effect Schema, so they read like the rest of your Model: capitalized literals, booleans named is…. Options named true and false make a boolean dimension.

button.tsTypeScript
import { Recipe, Style, When } from '@pleat/core'

import { tokens } from './tokens.ts'

const { color, space } = tokens

export const button = Recipe.make({
  name: 'Button',
  base: Style.make({
    display: 'inline-flex',
    gap: space[2],
    borderRadius: 8,
  }).pipe(
    Style.when(When.disabled, {
      opacity: 0.5,
      cursor: 'not-allowed',
    }),
  ),
  variants: {
    tone: {
      Primary: Style.make({
        backgroundColor: color.accent,
        color: color.canvas,
      }),
      Neutral: Style.make({
        backgroundColor: color.canvas,
        color: color.ink,
      }),
    },
    size: {
      Small: { padding: space[2] },
      Medium: { padding: space[4] },
    },
    isPending: { true: { cursor: 'progress' }, false: {} },
  },
  defaults: { tone: 'Neutral', size: 'Medium', isPending: false },
  compounds: [
    {
      when: { tone: 'Primary', isPending: true },
      style: { opacity: 0.8 },
    },
  ],
})

button({ tone: 'Primary' }) // a Style, the same object every time
export const buttonSchema = button.schema // an Effect Schema for the props

Every option’s style exists before the recipe is first called. Calling it picks one combination and memoizes the merged style, so equal props return the same object. Recipe.combinations lists them all, for galleries and tests.

Continuous values

Values that change continuously with the Model, like a percentage or a drag offset, are not variants. Give them a typed Var: rules refer to it statically, and each render binds a value in one inline custom property.

progress.tsTypeScript
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Style, Var } from '@pleat/core'
import { css } from '@pleat/foldkit'

// Continuous values go through a typed custom property. The rule is
// static; each render binds one inline --progress declaration.
const progress = Var.number('progress')

const fill = Style.make({
  width: `calc(${progress} * 1%)`,
  height: 6,
  backgroundColor: 'currentColor',
})

export const progressView = <Message>(
  percent: number,
  h: HtmlBuilder<Message>,
): Html =>
  h.div([
    h.Role('progressbar'),
    ...css(fill, Var.bind(progress, percent)),
  ])

Server rendering and static pages

Export Pleat’s renderDocument from your server entry in place of Foldkit’s. Each page then carries a <style data-pleat> with the rules it uses, and the browser adds others as views use them. Class names are hashes of the declarations, so the server and the client compute the same ones and hydration matches.

entry.server.tsTypeScript
import { Effect } from 'effect'
import { Server } from 'foldkit/experimental'

import { renderDocument as renderWithPleat } from '@pleat/foldkit/server'

import { init, view } from './main.ts'

// Each page gets a <style data-pleat> with the rules it uses. The
// browser inserts any others the first time a view uses them.
export const renderDocument = renderWithPleat({
  head: '<link rel="icon" href="/icon.svg">',
})

export const renderPage = (
  request: Request,
): Promise<Server.EntryResult> =>
  Effect.runPromise(
    Server.renderToString(
      { routing: {}, init, view },
      { url: request.url },
    ).pipe(Effect.map(Server.Rendered)),
  )

For a static site, nothing else changes: Foldkit’s prerender calls the same renderDocument for every path. This site is built that way and deployed to Cloudflare with Alchemy’s Cloudflare.Website.StaticSite.

Alongside Tailwind

Foldkit’s starter uses Tailwind, and you can keep it while you move over. Pass Tailwind classes through className(...) in the same css() call so the element still gets one Class attribute. Unlayered Tailwind utilities win over Pleat’s layered rules, which makes them a safe escape hatch.

h.div([...css(card, className('prose lg:prose-lg'))], children)