Lesson 10 of 14

Type, motion and computed values

A font family and text styles, a transition, and a hover colour derived from the accent.

#The idea

Three things a system says once and uses everywhere: what its text looks like, how its surfaces move between states, and which values are derived from which. Each is a token with a shape of its own.

#What this lesson adds

milda
token font/sans : font-family = { family: "Inter"; fallback: sans-serif; source: google; license: "OFL-1.1" }
token body : typography = { family: font.sans; size: 15px; weight: 400; line-height: 1.5 }
token title : typography = { family: font.sans; size: 18px; weight: 700; line-height: 1.3 }

token shadow/card : elevation = raw "0 4px 12px -2px rgba(15,23,42,0.12)"
token fast : duration = 120ms
token standard : easing = raw "cubic-bezier(0.2, 0, 0, 1)"
milda
token accent-hover : color = compute accent in oklch { darken 10% }
milda
motion {
  transition fill ink duration fast easing standard
}

#What to notice

  • A font family is a record, not a CSS stack. family, fallback, source (system, google or custom) and licence. The source is what a generator needs to know whether to link a stylesheet, write an @font-face, or do nothing.
  • A text style bundles the axes - family, size, weight, line height, letter spacing - and family: font.sans is a token path, so the style stays tied to a family that can differ per context. A part paints with text body; and override one axis of the role.
  • A computed value follows its base. compute accent in oklch { darken 10% } replaces the hand-picked hover colour: change the accent and the hover follows. The steps a token accepts follow its type, so on a length is an error.
  • Motion decorates transitions. A names the facets that animate and the tokens that time them. It adds no state; the behaviour states from lesson 4 are the anchors.
  • Elevation is honest about being platform-shaped. The shadow here is a string, which a reader for another target carries without claiming to understand. A system that targets several platforms aliases an elevation token by Platform instead, so the web gets a shadow and Android a tonal step.

#What the generator makes of it

The theme imports the Google stylesheet for Inter and sets the family on :root; the hover colour is written as the oklch() the computation produced; the Button's CSS gains a of background and colour timed by the two tokens.

#Try it

Change darken 10% to lighten 20%, then change the accent itself. Then add slant italic to the Card title.

Words in this lesson

  • Declares a design token.
  • An explicit typed value.
  • A value derived from another one, live: "the accent, 20% lighter".
  • How the part animates between its states.

Open a word for its entry, or read them all in the reference.

#The file at the end of this lesson

milda
milda 0.10
prelude 0.0.2

meta { name "Acme" }

context-group ColorScheme { Light Dark }

context-group Variant { Primary Secondary Ghost }

group Actions "Things a person presses to make something happen."
group Forms "Collecting input."
group Feedback "Telling a person what is happening."
group Layout "Surfaces that hold the rest."

foundations {
  layer Base {
    token violet/100 : color = hex(ede9fe)
    token violet/500 : color = hex(8b5cf6)
    token violet/600 : color = hex(7c3aed)
    token violet/700 : color = hex(6d28d9)
    token violet/900 : color = hex(4c1d95)
    token gray/50 : color = hex(f8fafc)
    token gray/200 : color = hex(e2e8f0)
    token gray/500 : color = hex(64748b)
    token gray/800 : color = hex(1e293b)
    token gray/900 : color = hex(0f172a)
    token green/500 : color = hex(22c55e)
    token amber/500 : color = hex(f59e0b)
    token white : color = hex(ffffff)
  }

  layer Semantic {
    token accent : color = alias by ColorScheme { default -> violet.600; Dark -> violet.500 }
    token accent-hover : color = compute accent in oklch { darken 10% }
    token accent-subtle : color = alias by ColorScheme { default -> violet.100; Dark -> violet.900 }
    token on-accent : color = alias by ColorScheme { default -> white }
    token surface : color = alias by ColorScheme { default -> white; Dark -> gray.900 }
    token text : color = alias by ColorScheme { default -> gray.900; Dark -> gray.50 }
    token text-muted : color = alias by ColorScheme { default -> gray.500 }
    token line : color = alias by ColorScheme { default -> gray.200; Dark -> gray.800 }
    token success : color = alias by ColorScheme { default -> green.500 }
    token warning : color = alias by ColorScheme { default -> amber.500 }

    token space/xs : spacing = 4px
    token space/sm : spacing = 8px
    token space/md : spacing = 12px
    token space/lg : spacing = 16px
    token space/xl : spacing = 24px

    token radius/sm : radius = 6px
    token radius/lg : radius = 12px
    token radius/pill : radius = pill

    token hairline : border = 1px

    token size/sm : font-size = 13px
    token size/md : font-size = 15px
    token size/lg : font-size = 18px
    token weight/medium : font-weight = 500
    token weight/bold : font-weight = 700

    token font/sans : font-family = { family: "Inter"; fallback: sans-serif; source: google; license: "OFL-1.1" }
    token body : typography = { family: font.sans; size: 15px; weight: 400; line-height: 1.5 }
    token title : typography = { family: font.sans; size: 18px; weight: 700; line-height: 1.3 }

    token shadow/card : elevation = raw "0 4px 12px -2px rgba(15,23,42,0.12)"
    token fast : duration = 120ms
    token standard : easing = raw "cubic-bezier(0.2, 0, 0, 1)"
  }
}

component Button "The primary action, in three emphases." {
  archetype Button
  group Actions

  props {
    variant : context Variant = Primary "Which emphasis the button carries."
    disabled : boolean = false
    loading : boolean = false "Shows a spinner in place of the label."
    loadingLabel : string required when loading "Read out while the spinner replaces the label."
    label : string = "Get started"
    icon : string "A glyph shown before the label, by name."
  }

  events {
    click
  }

  part Button: container {
    emits { click on activate }
    bind state disabled disabled
    bind state loading loading
    layout { stack row; gap space.sm; align center; distribute center }
    fill accent
    ink on-accent
    border accent hairline
    corner radius.sm
    inset space.sm space.lg
    when variant = Secondary {
      fill surface
      ink text
      border line hairline
    }
    when variant = Ghost {
      fill surface
      ink accent
      border surface hairline
    }
    state hovered {
      fill accent-hover
      border accent-hover hairline
    }
    state hovered when variant = Secondary {
      fill accent-subtle
    }
    state hovered when variant = Ghost {
      fill accent-subtle
      border accent-subtle hairline
    }
    state focus-visible {
      ring accent
    }
    state disabled {
      opacity 50
    }
    motion {
      transition fill ink duration fast easing standard
    }
  }

  part icon: icon {
    content bind icon
    ink on-accent
    when variant = Secondary {
      ink text
    }
    when variant = Ghost {
      ink accent
    }
  }

  part label: text {
    slot of content default
    content bind label
    text body
    weight weight.medium
  }
}

component Badge "A small status pill." {
  archetype Badge
  group Feedback

  props {
    tone : enum(neutral, success, warning) = neutral
    label : string = "New"
  }

  part Badge: container {
    layout { stack row; align center }
    fill accent-subtle
    ink accent
    corner radius.pill
    inset space.xs space.sm
    when tone = success {
      fill success
      ink on-accent
    }
    when tone = warning {
      fill warning
      ink gray.900
    }
  }

  part label: text {
    slot of content default
    content bind label
    text size.sm
    weight weight.bold
  }
}

component Card "A surface composed from the components above." {
  group Layout
  props {
    tone : enum(neutral, success, warning) = neutral
  }

  part Card: container {
    layout { stack column; gap space.md; size fill fit }
    fill surface
    ink text
    border line hairline
    corner radius.lg
    inset space.xl
    elevation shadow.card
  }

  part Header: container {
    layout { stack row; gap space.sm; align center; distribute between }
  }

  part Header.Title: text {
    content "Project Aurora"
    text title
  }

  part Header.Status: instance {
    instance Badge {
      label = "Beta"
      tone = bind tone
    }
  }

  part Body: text {
    slot of content default
    ink text-muted
    text body
  }

  part Footer: container {
    layout { stack row; gap space.sm; distribute end }
  }

  part Footer.Primary: instance {
    instance Button {
      label = "Open"
    }
  }

  part Footer.Secondary: instance {
    instance Button {
      label = "Share"
      variant = "Secondary"
    }
  }
}

component TextField "A single-line input with an error state." {
  archetype TextField
  group Forms

  props {
    label : string required "What the field asks for. Also its accessible name."
    value : string
    placeholder : string = "you@example.com"
    invalid : boolean = false
    error : string "Shown under the control while invalid."
    disabled : boolean = false
  }

  events {
    valueChange
  }

  part TextField: container {
    bind state disabled disabled
    bind state error invalid
    layout { stack column; gap space.xs }
  }

  part label: text {
    content bind label
    text size.sm
    weight weight.medium
  }

  part control: input {
    content bind placeholder
    emits {
      valueChange on input payload bind value
    }
    bind value value
    value string
    a11y { labelled-by label; described-by hint }
    layout { size fill }
    fill surface
    ink text
    border line hairline
    corner radius.sm
    inset space.sm space.md
    text size.md
    state focus-visible {
      border accent hairline
    }
    state error {
      border warning hairline
    }
    state disabled {
      opacity 50
    }
  }

  part hint: text {
    present when invalid
    content bind error
    ink warning
    text size.sm
  }
}

component Switch "One boolean, as a sliding switch." {
  archetype BooleanInput
  group Forms

  props {
    checked : boolean = false "Whether it is on."
    label : string required
    disabled : boolean = false
  }

  events {
    checkedChange
  }

  part Switch: container {
    bind state disabled disabled
    layout { stack row; gap space.sm; align center }
  }

  part toggle: input {
    control switch
    a11y { name bind label }
    emits {
      checkedChange on change payload bind checked
    }
    bind value checked
    value boolean
  }
}

component Dialog "A blocking layer that asks one question." {
  archetype Dialog
  group Feedback

  uses presentation(surface: dialog) as P
  uses dismiss as D
  wire D.dismissRequested -> P.close

  props {
    trigger : string = "Open" "The label of the control that opens the dialog."
    title : string required "The dialog's accessible name."
    open : boolean = false
  }

  part Dialog: container {
    bind state open open
  }

  part trigger: control {
    layout { stack row; gap space.sm; align center; distribute center }
    fill accent
    ink on-accent
    corner radius.sm
    inset space.sm space.lg
    state focus-visible {
      ring accent
    }
  }

  part trigger.label: text {
    content bind trigger
    weight weight.medium
  }

  part backdrop: container {
    fill text
    opacity 50
  }

  part surface: container {
    layout { stack column; gap space.md }
    fill surface
    ink text
    corner radius.lg
    inset space.xl
  }

  part surface.title: text {
    content bind title
    text size.lg
    weight weight.bold
  }

  part surface.content: container {
    slot of content default
    ink text-muted
  }
}