Purrx

Slots, optional props and a component library

Let components take children through slots, drop optional parts when a prop is missing, and meet the slide component library the model will write with.

13 min+35 XPHands-on

Props handle short values like a title. But a bullet slide doesn't know how many bullets it will get, and a title slide's subtitle is optional. Two small additions to templates handle both.

You write

<BulletsSlide title="Why now">
  <Bullet>Labor is scarce</Bullet>
  <Bullet>Payback in a year</Bullet>
</BulletsSlide>

The template has

<slide padding="48" gap="24">
  <text role="heading">{title}</text>
  <column grow="1" gap="14">
    <slot/>  ← both Bullets go here
  </column>
</slide>
Whatever you put between a component's tags replaces its <slot/>. A BulletsSlide doesn't know how many bullets it will get, and doesn't need to.

Slots

A is where a component's children go. When fillTemplate meets <slot/>, it returns the children that were written between the component's tags: zero, one or ten of them.

That's why fillTemplate now returns an array of nodes. One element becomes a one-item array, a slot becomes however many children there are, and an element that's left out becomes an empty array. Parents combine their children's arrays with flatMap.

Optional parts: if

TitleSlide's template (part)
<text role="title" fontSize="$title" bold="true">{title}</text>
<text if="subtitle" fontSize="$heading">{subtitle}</text>

With no subtitle prop, the second text isn't an empty box taking up space: it isn't there at all.

The library

This lesson adds src/library.js with the components every deck is built from:

  • Slides: TitleSlide, BulletsSlide, CardsSlide, StatsSlide, QuoteSlide, ClosingSlide
  • Blocks inside them: Bullet, Card, Stat

Your project so far

15 files · 2 new or changed in this lesson

src/library.js

// The component library: the building blocks the model is allowed to use.
// Each template is ordinary SlideML with {props}, <slot/> and if="prop".
// Sizes and colors are theme tokens, so every component follows the theme.
// Slide headings get a fixed two-line area (height="72"), so titles line up from
// slide to slide and a long title shrinks instead of pushing the content down.

import { defineComponent } from "./components.js";

const components = [
  defineComponent({
    name: "TitleSlide",
    description: "Opening slide with a big title on a colored background.",
    props: {
      title: { required: true, description: "The deck title, under 8 words" },
      subtitle: { description: "One short line under the title" },
    },
    template: `
      <slide fill="$primary" padding="56" gap="16">
        <box grow="1"/>
        <text role="title" fontSize="$title" bold="true" color="$onPrimary">{title}</text>
        <text if="subtitle" fontSize="$heading" color="$onPrimary">{subtitle}</text>
        <box grow="1"/>
      </slide>`,
  }),

  defineComponent({
    name: "BulletsSlide",
    description: "A title and 2 to 5 bullet points. Children: <Bullet> elements.",
    props: { title: { required: true, description: "Slide title, under 8 words" } },
    template: `
      <slide padding="48" gap="24">
        <text role="heading" height="72" valign="bottom" fontSize="$heading" bold="true">{title}</text>
        <column grow="1" gap="14"><slot/></column>
      </slide>`,
  }),

  defineComponent({
    name: "Bullet",
    description: "One bullet point. The text goes inside: <Bullet>Short point</Bullet>",
    template: `
      <row gap="12">
        <text width="18" fontSize="$body" color="$primary" bold="true">•</text>
        <text fontSize="$body"><slot/></text>
      </row>`,
  }),

  defineComponent({
    name: "CardsSlide",
    description: "A title and 2 to 4 side-by-side cards. Children: <Card> elements.",
    props: { title: { required: true, description: "Slide title, under 8 words" } },
    template: `
      <slide padding="48" gap="24">
        <text role="heading" height="72" valign="bottom" fontSize="$heading" bold="true">{title}</text>
        <row grow="1" gap="20"><slot/></row>
      </slide>`,
  }),

  defineComponent({
    name: "Card",
    description: "A card with a heading and one or two sentences inside: <Card title=\"…\">Text</Card>",
    props: { title: { required: true, description: "Card heading, 1 to 3 words" } },
    template: `
      <box fill="$surface" padding="20" gap="10">
        <text fontSize="$body" bold="true" color="$primary">{title}</text>
        <text grow="1" fontSize="$small"><slot/></text>
      </box>`,
  }),

  defineComponent({
    name: "StatsSlide",
    description: "A title and 2 to 4 big numbers. Children: <Stat> elements.",
    props: { title: { required: true, description: "Slide title, under 8 words" } },
    template: `
      <slide padding="48" gap="24">
        <text role="heading" height="72" valign="bottom" fontSize="$heading" bold="true">{title}</text>
        <row grow="1" gap="20"><slot/></row>
      </slide>`,
  }),

  defineComponent({
    name: "Stat",
    description: "One big number with a label: <Stat value=\"40%\" label=\"faster setup\"/>",
    props: {
      value: { required: true, description: "A short number like 40% or $2M" },
      label: { required: true, description: "What the number means, under 6 words" },
    },
    template: `
      <box fill="$surface" padding="20" gap="8">
        <box grow="1"/>
        <text fontSize="$stat" bold="true" color="$primary" align="center">{value}</text>
        <text fontSize="$small" color="$muted" align="center">{label}</text>
        <box grow="1"/>
      </box>`,
  }),

  defineComponent({
    name: "QuoteSlide",
    description: "A single memorable quote.",
    props: {
      quote: { required: true, description: "The quote, under 25 words" },
      author: { description: "Who said it" },
    },
    template: `
      <slide fill="$surface" padding="72" gap="20">
        <box grow="1"/>
        <text fontSize="$heading">“{quote}”</text>
        <text if="author" fontSize="$body" color="$muted">- {author}</text>
        <box grow="1"/>
      </slide>`,
  }),

  defineComponent({
    name: "ClosingSlide",
    description: "Final slide: a call to action or thank-you.",
    props: {
      title: { required: true, description: "Closing message, under 6 words" },
      subtitle: { description: "Contact details or next step" },
    },
    template: `
      <slide fill="$primary" padding="56" gap="16">
        <box grow="1"/>
        <text role="title" fontSize="$title" bold="true" color="$onPrimary" align="center">{title}</text>
        <text if="subtitle" fontSize="$body" color="$onPrimary" align="center">{subtitle}</text>
        <box grow="1"/>
      </slide>`,
  }),
];

/** Components by tag name, which is how the compiler looks them up. */
export const LIBRARY = Object.fromEntries(components.map((component) => [component.name, component]));

Key takeaways

  • <slot/> in a template is replaced by whatever was written inside the component's tag.
  • if="subtitle" keeps an element only when that prop was given.
  • A small library of slide components covers most decks, and every one follows the theme.

Sign in to run the exercise

Reading is free. Writing code here needs an account so we have somewhere to keep your Gemini key and the +35 XP you are about to earn.