Kitten

Learn how to break up your pages and apps into smaller, well-encapsulated, and easily maintainable components and fragments.

Topics covered

Better coding through components and fragments

The Fetchiverse example in the last tutorial is about 100 lines of code in a single file, with a 60/40 split of CSS and Kitten HTML.

While that’s fine for something so simple, in larger projects, it would help us to maintain our code if we break it up into smaller components and fragments.

Let’s start with the obvious refactor: pulling out our styles into an external stylesheet.

Working with external stylesheets

There are two ways we can work with external stylesheets in Kitten:

  1. The traditional way: use a regular external .css file and import it on the client.

  2. The Kitten way: use a CSS fragment (a .fragment.css file), import it on the server and embed it in the page.

The major advantage of the first way of doing things is that it’s a well-known pattern that everyone knows. The major disadvantage is that the browser has to make as many HTTP calls as you have external stylesheets and this can impact the performance of your page.

The major advantage of the second way of doing things is that you send everything over in one HTTP call.

Client-side stylesheets

Here’s how you can implement the traditional way:

  1. Create a file called index.css and cut and paste the styles from the previous tutorial:

    body {
      font-family: sans-serif;
      font-size: 1.25em;
      padding-left: 1.5em;
      padding-right: 1.5em;
    }
    
    h1 {
      font-size: 2.5em;
      text-align: center;
    }
    
    p {
      line-height: 1.5;
    }
    
    p:first-of-type {
      margin-top: 0;
    }
    
    a:not(.avatar-link) {
      background-color: #8bdaff;
      color: black;
      text-decoration: none;
      padding: 0.25em;
      border-radius: 0.25em;
      /* Make sure posts don’t overflow their containers. */
      overflow-wrap: anywhere;
    }
    
    ul {
      padding: 0;
    }
    
    li {
      display: flex;
      align-items: flex-start;
      column-gap: 1em;
      padding: 1em;
      margin-bottom: 1em;
      background-color: #ccc;
      border-radius: 1em;
    }
    
    .avatar {
      width: 8em;
      border: 6px solid #aaa;
      border-radius: 1em;
    }
    
    .content {
      /* Grow to fill all remaining space in the row. */
      flex: 1;
    }
    
    .image {
      max-width: 100%;
    }
    
  2. Then, in your index page, remove the now-empty <style> tag and, instead, import the stylesheet at runtime using the <link> tag:

      export default async function () {
        const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
        const posts = await postsResponse.json()
    
        return kitten.html`
          <link rel='stylesheet' href='./index.css'>
          <h1>Aral’s Public Fediverse Timeline</h1>
          <ul>
            

          </ul>
        `
      }
    

While that works, it’s not ideal.

If you open up the web inspector/developer tools panel of your browser, you will see that the <link> tag gets added to the body of the page.

That’s not what we want as that’s not valid HTML (browsers are forgiving but writing valid HTML will safe you headaches down the line).

So let’s tell Kitten to place that tag in the <head> of our page instead.

We can do that using the Kitten-specific <content for=''>
</content> tag:

  export default async function () {
    const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
    const posts = await postsResponse.json()

    return kitten.html`
      <content for='${kitten.page.head}'>
        <link rel='stylesheet' href='./index.css'>
      </content>
      <h1>Aral’s Public Fediverse Timeline</h1>
      <ul>
        

      </ul>
    `
  }

You will get a proper introduction to the <content> tag in the Special page slots tutorial.

kitten.page.head is a constant provided by Kitten to aid you while authoring. If you have type-safety enabled for your project, you can type kitten.page. in your editor and see all the constants that refer to different parts of the page that you can add content to using the <content> tag. You can also use magic strings – the values of those constants – directly, but you won’t get warnings if you type the wrong thing in. So the above example will still work if you write <content for='HEAD'>
</content> but it’s not recommended to do so.

Screenshot of the Safari Web Inspector showing the link tag under the head of the page in the Elements tab.
This time, the <link> tag is correctly added to the head of the page.

CSS Fragments

Fragments are pieces of content stored in external files that you can import and add to your Kitten pages.

Fragments might contain dynamic content (like JavaScript) or static content (like CSS).

Let’s refactor our example to use a server-side CSS fragment instead of an client-side CSS stylesheet to save us one HTTP call on the page. It’s very easy to do:

  1. Rename your index.css file to index.fragment.css.

  2. In your index page, import the fragment file as if you were importing a JavaScript file and then interpolate it into your Kitten HTML:

    import styles from './index.fragment.css'
    
    export default async function route () {
      const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
      const posts = await postsResponse.json()
    
      return kitten.html`
        <${styles} />
        <h1>Aral’s Public Fediverse Timeline</h1>
        <ul>
          

        </ul>
      `
    }
    

When the page refreshes in your browser, you should see the styles exactly as they were before.

However, instead of being loaded in via a separate HTTP call by the browser, you can find them in a style tag in the head of the page.

Look, ma, I embedded my styles and saved an HTTP call!

Wait a minute
 how did Kitten know to place our styles in the <head> when we didn’t use the <content> tag to specify that they should be placed there?

Kitten automatically hoists any <style> tags it finds in your pages to the head of the page, maintaining the order in which they found them (so that it doesn’t affect the CSS cascade).

There will be times, however, when you don’t want Kitten to do that. Specifically, you might want to include an inline style tag if you want to use the @scope at-rule to add scoped styles to your components. You create inline style tags by adding the inline attribute on a style tag:

<style inline>
  

</style>

The actual attribute name you use doesn’t matter. The rule is that Kitten only moves tags that match the exact regular expression <style>.*?<\/style>.

When Kitten encounters inline style tags, it leaves them where they are and doesn’t hoist them to the head of the page.

Components

Right, now that we’ve separated the presentation (our styles; CSS) from the structure and functionality of our page (our Kitten HTML), let’s next look at how we can break down structure and functionality the page into smaller pieces (components and fragments) that are easier to reason about and maintain.

Let’s start by examining the layout of our list:

<ul>
  ${posts.map(post => (
    kitten.html`
      <li>
        
<!-- Avatar. --> <a class='avatar-link' href='${post.account.url}'> <img class='avatar' src='${post.account.avatar}' alt='${post.account.username}’s avatar' > </a>
<!-- Content --> <div class='content'> ${kitten.safelyAddUntrustedHtml(post.content)} ${post.media_attachments.map(media => ( media.type !== 'image' ? '' : kitten.html` <img class='image' src='${media.url}' alt='${media.description ?? ''}'> ` ))} </div>
</li> `
))} </ul>

We have two major elements in each list item: the author’s avatar and the post content itself. These are prime candidates to make into separate fragments or components.

So let’s do that and, for the time being, just pull out those components into the same file, simplifying them when we can and inlining the styles that pertain to each:

index.page.js

import styles from './index.fragment.css'

const avatar = ({ post }) => kitten.html`
  <a href='${post.account.url}'>
    <img src='${post.account.avatar}' alt='${post.account.username}’s avatar' />
    <style inline>
      @scope {
        img {
          width: 8em;
          border-radius: 1em;
        }
      }
    </style>
  </a>
`

const content = ({ post }) => kitten.html`
  <div>
    ${kitten.safelyAddUntrustedHtml(post.content)}
    ${post.media_attachments.map(media => (
      media.type === 'image' && kitten.html`
        <img src='${media.url}' alt='${media.description ?? ''}'>
      `
    ))}
    <style inline>
      @scope {
        /* Grow to fill all remaining space in the row. */
        flex: 1;
        p { line-height: 1.5; }
        p:first-of-type { margin-top: 0; }
        a {
          background-color: #8bdaff;
          color: black;
          text-decoration: none;
          padding: 0.25em;
          border-radius: 0.25em;
          /* Make sure posts don’t overflow their containers. */
          overflow-wrap: anywhere;
        }
        img { max-width: 100%; }
      }
    </style>
  </div>
`

export default async function () {
  const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
  const posts = await postsResponse.json()

  return kitten.html`
    <${styles} />
    <h1>Aral’s Public Fediverse Timeline</h1>
    <ul>
      ${posts.map(post => kitten.html`
        <li>
          <${avatar} post=${post} />
          <${content} post=${post} />
        </li>
      `)}
    </ul>
  `
}

index.fragment.css

body {
  font-family: sans-serif;
  font-size: 1.25em;
  padding-left: 1.5em;
  padding-right: 1.5em;
}

h1 {
  font-size: 2.5em;
  text-align: center;
}

ul {
  padding: 0;
}

li {
  display: flex;
  align-items: flex-start;
  column-gap: 1em;
  padding: 1em;
  margin-bottom: 1em;
  background-color: #ccc;
  border-radius: 1em;
}

Notice how we split up the styles also and encapsulated those that pertain to the components in the components themselves, scoping them to the component itself using the @scope CSS at-rule.

While @scope is great for components as it allows you to easily customise the appearance of individual instances of components and encapsulate your styles especially when other people might use your components in their projects, it must be used with inline styles.

You cannot use @scope with regular style tags as they get hoisted to the head by Kitten.

If you don’t need to customise the styles of individual instances of your components, you can use regular style tags in your components, in which case Kitten will intelligently deduplicate the styles and only include one style tag in the head. So if you have 1,000 of the same component, you won’t get 1,000 copies of the styles also. (If you use <style inline>, you will, by design.)

Importing and using components

Once you’ve separated your page into components and fragments, there’s no rule that says they must all remain in the same file. Since they’re just snippets of JavaScript, you can put each one in its own file and import them in to make your project easier to organise and maintain.

In Kitten, we call a custom HTML element that can be included as a custom tag a component and any other snippet of HTML, CSS, or JavaScript a fragment. They go in .component.js and .fragment.js files, respectively.

A good rule of thumb is that if an element will be reused in multiple places on a page and/or in multiple pages, it’s a component. If it is a part of page that might be rendered separately from it (or makes sense to separate for organisational reasons to make maintenance easier), it’s a fragment.

If you’re using a component for layout, you can also use a .layout.js extension. Like every other Kitten-specific extension, these are just JavaScript files but Kitten knows that these are server-side routes and should not be served as client-side JavaScript.

See: Layout components tutorial

So here’s one way we could organise the code:

index.page.js

import styles from './index.fragment.css'
import avatar from './avatar.component.js'
import content from './content.component.js'

export default async function () {
  const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
  const posts = await postsResponse.json()

  return kitten.html`
    <${styles} />
    <h1>Aral’s Public Fediverse Timeline</h1>
    <ul>
      ${posts.map(post => kitten.html`
        <li>
          <${avatar} post=${post} />
          <${content} post=${post} />
        </li>
      `)}
    </ul>
  `
}

avatar.component.js

export default function ({ post }) {
  return kitten.html`
    <a href='${post.account.url}'>
      <img
        src='${post.account.avatar}'
        alt='${post.account.username}’s avatar'
      >
      <style inline>
        @scope {
          img {
            width: 8em;
            border: 6px solid #aaa;
            border-radius: 1em;
          }
        }
      </style>
    </a>
  `
}

content.component.js

export default function ({ post }) {
  return kitten.html`
    <div>
      ${kitten.safelyAddUntrustedHtml(post.content)}
      ${post.media_attachments.map(media => (
        media.type === 'image' && kitten.html`
          <img src='${media.url}' alt='${media.description ?? ''}'>
        `
      ))}
      <style inline>
        @scope {
          /* Grow to fill all remaining space in the row. */
          flex: 1;
          p { line-height: 1.5; }
          p:first-of-type { margin-top: 0; }
          a {
            background-color: #8bdaff;
            color: black;
            text-decoration: none;
            padding: 0.25em;
            border-radius: 0.25em;
            /* Make sure posts don’t overflow their containers. */
            overflow-wrap: anywhere;
          }
          img { max-width: 100%; }
        }
      </style>
    </div>
  `
}

index.fragment.css

body {
  font-family: sans-serif;
  font-size: 1.25em;
  padding-left: 1.5em;
  padding-right: 1.5em;
}

h1 {
  font-size: 2.5em;
  text-align: center;
}

ul {
  padding: 0;
}

li {
  display: flex;
  align-items: flex-start;
  column-gap: 1em;
  padding: 1em;
  margin-bottom: 1em;
  background-color: #ccc;
  border-radius: 1em;
}

Separating your pages into components and fragments should make your sites easier to maintain but this does come at the expense of locality of behaviour.

Locality of behaviour is about keeping related functionality together so you can easily read through the code in a linear fashion. So there might be times you might keep simple components and fragments in the same file as your page routes.

Don’t be afraid to experiment with how you organise your own projects. Soon, you’ll develop a knack for knowing when you’ve hit the sweet spot.

Component idiom

The component idiom in Kitten is for your components to have a single root element.

You can have a component with multiple root elements, but Kitten can’t stream that component back to the client (see Streaming HTML). It sends nothing and logs the following error:

❌ Error: Cannot stream component update as component does not have a single root DOM node.

HTML attributes

In addition to the custom properties (or “props”) you can define and set on your components, you can also pass all other props (HTML attributes) to an element of your choosing within your component.

This is very useful as it means that you can use regular HTML attributes to listen for events and use frameworks like htmx and Alpine.js with your components.

Kitten’s Streaming HTML workflow actually uses htmx under the hood and Kitten also has first-class support for Alpine.js if you ever find yourself having write custom client-side JavaScript. (You can, of course, always use vanilla client-side JavaScript in your pages.)

Take this simple example of a custom button. Notice how we’re passing the rest of the props – obtained using the spread operator – to the button element. This allows us to listen for the onclick event and display an alert dialogue in response to it.

myButton.component.js

export default function ({label = 'Missing label prop (label="
")', ...props}) {
  return kitten.html`
    <div>
      <button ...${props}>${label}</button>
      <style inline>
        @scope {
          button {
            border: 2px solid darkviolet;
            color: darkviolet;
            border-radius: 0.25em;
            padding: 0.25em 0.5em;
            background: transparent;
            font-size: 2em;
          }
        }
      </style>
    </div>
  `
}

index.page.js

import myButton from './myButton.component.js'

export default () => kitten.html`
  <h1>My button</h1>

  <${myButton} label='Press me!' onclick='alert("Thank you for pressing me!")' />
`

If, instead you wanted to use Alpine.js, this is how your index page would look:

import myButton from './myButton.component.js'

export default () => kitten.html`
  <page alpinejs>

  <h1>My button</h1>
  <${myButton}
    label='Press me!'
    x-data
    @click='alert("Thank you for pressing me!")'
  />
`

For more advanced components, you can, of course, use htmx and Alpine.js inside your component and expose just your custom interface.

HTML, CSS, and Markdown Fragments

In addition to regular (JavaScript) components and fragments, you can also create HTML, CSS, and Markdown fragments.

These are plain old HTML, CSS, and Markdown files that you can nevertheless import as if they were JavaScript functions. (Kitten uses the magic of ES Module Loaders to convert them to JavaScript modules.

You place HTML fragments into files that have a .fragment.html extension, CSS fragments into files that have a .fragment.css extension, and Markdown files into – you guessed it – files that have a .fragment.md extension.

You already used the CSS fragment in this example.

Like JavaScript fragments, HTML, CSS, and Markdown fragments are ignored by the router.

For an example of their use, see the examples/html-and-css-fragments and examples/fragment-loaders in the Kitten source code.

Now that you have a taste for what you can do with components and fragments, let’s leave the Fetchiverse example aside for the time being and take a look at a powerful feature of components and fragments that you haven’t used yet: composition using slots.

Next tutorial: Slots

Like this? Fund us!

Small Technology Foundation is a tiny, independent not-for-profit.

We exist in part thanks to patronage by people like you. If you share our vision and want to support our work, please become a patron or donate to us today and help us continue to exist.