Skip to main content

Command Palette

Search for a command to run...

3 Examples of the TAC Methodology In Action

Updated
9 min read
J

Frontend engineer

In this post, we're going to build three components, each of which demonstrates how to follow different parts of the TAC CSS methodology.

If you haven't read about the TAC CSS methodology you can still follow along, but to fully understand some of the details in the examples below I'd suggest reading that.

Badge evolved...

This example demonstrates how a TAC CSS component can evolve to a JavaScript-enhanced Custom Element.

In the original TAC article, we were introduced to a Badge component. HTML didn't have a tag for us to use, so we defined a custom x-badge tag. We also defined a custom count attribute for setting the number and hiding/showing the Badge. It was all done with just CSS. The whole component was as simple as this:

/* badge.css */

/* Base styles */
x-badge {
  display: none;
  min-width: 1.25rem;
  height: 1.25rem;
  border-radius: 0.625rem;
  place-content: center;
  background-color: blue;
  color: white;
}

/* Count attribute */
x-badge[count]:before {
  content: attr(count);
  padding: 0 6px;
}

/* Display Badge when count is greater than zero */
x-badge[count]:not([count=""]):not([count="0"]) {
  display: inline-flex;
}

Now let's imagine we're using this Badge component to highlight the number of open orders in a food truck point-of-sale web app. User feedback suggests a notification sound for new orders would be helpful to cooks working under a noisy exhaust fan. This means we'll need to add JavaScript to our CSS component. The status quo has been to rebuild components with React because older CSS methodologies don't have a backward-compatible path for adding JavaScript features.

Unfortunately, nothing mainstream has worked well in the Web Components era because all existing CSS methodologies were based on classes, not tags, and React could not and would not be made to play nice with Web Components, hence the status quo.

Anyway, the notification bell. TAC was designed for such an evolution! We simply add a Custom Elements definition that plays a sound when count goes up:

// badge.js
customElements.define('x-badge', class extends HTMLElement {
  #soundEffect;

  constructor() {
    super();
    this.#soundEffect = new Audio('bell.wav');
  }

  static get observedAttributes() {
    return ['count'];
  }

  attributeChangedCallback(name, oldVal, newVal) {
    // If new count is greater than the old count,
    // ring the notifcation bell.
    if (name === 'count' && Number(newVal) > Number(oldVal)) {
      this.#soundEffect.play();

      // Here's another fun option using the SpeechSynthesis API.
      // Define a "notify" attribute where, if present, plays 
      // the notification bell, but if set announces the text.
      // For example:
      // Plays bell
      // <x-badge count="<update_count>" notify>
      // Says: "New order"
      // <x-badge count="<update_count>" notify="New order">
      if (this.hasAttribute('notify')) {
        const text = this.getAttribute('notify');
        if (text) {
          const utterance = new SpeechSynthesisUtterance(text);
          window.speechSynthesis.speak(utterance);
        }
        else {
          this.#soundEffect.play();
        }
      }
    }
  }
})

Nothing else changes. An app's code doesn't need to change. Badge's styles remain untouched and working as before. There is nothing to install or compile or configure and there are zero proprietary libraries to manage going forward. It's the ideal evolutionary path for components.

If you're building a design system, this is even more remarkable as we have introduced JavaScript in a way that creates no conflicts with existing tech stacks. You could have a Django app, a Gatsby site, and a Vue SPA and all of them could seamlessly consume this Badge component. That's the power of web standards in your design system!

Customize HTML

This second example shows how to add custom attributes to native HTML elements.

The first step when following the TAC methodology is to identify a component tag, and the first place to look for tags is HTML itself.

There are over 100 HTML tags and we should be using every single one that matches our use cases. Reinventing tags or adding an abstraction layer between native tags and the developer contradicts the philosophy of TAC and doing so overlooks the value of vanilla HTML and wastes engineering resources (and can be very frustrating when you don't get direct access to native element features!).

So, leverage HTML. Take advantage of all HTML offers and then add to it when needed. Here's a simple example of doing that for buttons:

Native HTML button with a custom "ordinal" attribute
<style>
  /* Base styles for ordinal buttons */
  button[ord] { ... }

  /* Ordinal-specific styles */
  button[ord=primary] { ... }
  button[ord=secondary] { ... }
  button[ord=tertiary] { ... }
</style>

<button ord="primary">Primary button</button>
<button ord="secondary">Secondary button</button>
<button ord="tertiary">Tertiary button</button>

This hierarchy of buttons is an extremely common design pattern and it's common to name them using ordinal number words. In the past, that often looked like:

<button class="btn btn-primary">
or
<button class="button button--primary">

Nothing wrong with that, but there's a better way.

Because this ranking of buttons has meaning beyond generic styles it is considered a variation. TAC follows HTML's lead and implements variations as attributes, not classes. In this case, HTML offers nothing that can be leveraged for an ordinal attribute (aria-level is not good here), so we defined a custom enumerated attribute with the values “primary”, “secondary”, and “tertiary”.

But like the class-based examples above, TAC is still using just CSS. No JavaScript is interfering here with the native button element, so even if HTML were to add an ord attribute there is no harm done because the implementation is done with CSS only and developers are free to style elements based on their attributes.

Furthermore, this approach leaves the plain <button> element untouched because button elements are only styled in this case if the custom ord attribute is used.

This approach of looking really closely at HTML, using all its relevant tags and attributes and then adding more as needed is a very powerful method of scaling the UI layer in a way that remains surprisingly lightweight, framework-agnostic, and retains all the advantages of HTML (e.g. SEO, accessibility, easy to code and test, IDE-ready, debuggable, and portable). Here are a few more examples of things you can do to HTML:

Add 'none' to list's (deprecated) type attribute
<style>
  ul[type=none] { list-style: none }
</style>
<ul type="none">
  <li>No bullets</li>
</ul>

Define custom theme attribute for the details element
<style>
  details[theme=none] { ... }
  details[theme=toggle] { ... }
</style>
<details theme="none">
  <summary>Unstyled, but still interactive</summary>
  ...
</details>
<details theme="toggle">
  <summary>Has a custom expand & collapse style</summary>
  ...
</details>

Define a custom 'striped' boolean attribute for tables
<style>
  table[striped] tbody tr:nth-of-type(odd) { background-color: lightgray }
</style>
<table striped>
  <tbody>
    <tr>Background</tr>
    <tr>No background</tr>
    <tr>Background</tr>
    <tr>No background</tr>
  </tbody>
</table>

Sort arrows - nothing custom is needed for table sort arrows. All leverage!
<style>
  th[aria-sort] button { all: unset; cursor: pointer }
  th[aria-sort=ascending] button:after { content: '↑' }
  th[aria-sort=descending] button:after { content: '↓' }
</style>
<th aria-sort="ascending">
  <button aria-pressed="true" aria-label="Sort things"></button>
</th>

This can understandably make some developers nervous. Yes, it is possible, unlikely, but possible that the things you add to HTML could collide with future versions of HTML. Say WHATWG adds striped to table's spec next year. This does not automatically mean there will be issues - we're just setting a background color - but there could be so it's important to keep that in mind as you add customizations.

Creating Relationships

These examples show how relationships can be used to create more complex components.

Some components are really simple, like Badge, or custom buttons, or a striped table, but what about more complicated components? Something like a site header or a dialog? TAC can do it all. Let's start with the latter.

Dialog (aka Modal)

Let's use dialog to prove a quick point.

Here we have what traditionally would be a more complicated JavaScript-based component with lots of considerations for keyboard interaction, like pressing the Escape key, and z-index management, which is typically avoided by using JavaScript to awkwardly append modal to <body>. But since we're following TAC our guiding principle is to leverage what already exists and the first step is to identify a tag, starting with HTML tags first. And in this case, HTML has <dialog>.

The Dialog element is a great starting point. It offers us a lot of API to work with and has a lot of accessibility baked in. Leverage that!

The only thing we can't get from the native dialog is a close button. No problem. Define a slot for the basic dialog close button:

<style>
  dialog button[slot=close] { ... }
  dialog button[slot=close]:before { content: '×' }
</style>
<dialog>
  <button slot="close" aria-label="Close dialog"></button>
  <div>Dialog content...</div>
</dialog>

The exact styling of the close button is up to you. What's important is we’re leveraging HTML and creating a custom relationship between the dialog element and the button element with a slot attribute, i.e. a button[slot=close] child with a dialog parent. The application would bind a click handler to the close button and use dialog’s close() method to close it (or remove dialog's open attribute).

It's worth noting that in practice a basic close button (usually positioned in the top-right corner) is needful, but dialogs most often use an action/cancel pattern. Using our ordinal buttons from above, we could define a relationship between dialog and those as well.

Site header

In this example, we'll define a rigid element structure to ensure a required style and layout, but still leave it open for your content. The styles are going to be very basic because the concept is what we're learning here, not CSS.

As usual, we start with defining a tag and first look to HTML. In this case, we have <header>, which we will use, but because header elements can be used in many places and this one is unique, it needs to be a child of a custom tag, so let's define this basic relationship:

x-siteheader {
  padding: 12px;
  background-color: lightgray;
}

x-siteheader,
x-siteheader > header {
  display: flex;
  align-items: center;
}

The required markup is then:

<x-siteheader>
  <header>
  </header>
</x-siteheader>

Site headers need a logo, so let's build that into the component:

x-siteheader:before {
  content: '';
  width: 50px;
  height: 50px;
  background-image: url("logo.png");
}

Site headers have links, so let's define the structure and styles for those:

x-siteheader > header > nav > a {
  padding: 6px
}
x-siteheader > header > nav > a:hover {
  background-color: lightslategray;
}

Now the required markup is:

<x-siteheader>
  <header>
    <nav>
      <a href="home">Home</a>
      <a href="products">Products</a>      
      <a href="support">Support</a>
    </nav>
  </header>
</x-siteheader>

At this point, we have a custom HTML tag that defines our site header component, it has a built-in logo, and enforces a semantic structure for navigation. Let's take it further by adding a section for extra header elements.

x-siteheader-extras {
  display: flex;
  align-items: center;
  gap: 12px
}

x-siteheader-extras > button:hover,
x-siteheader-extras > a:hover {
  background-color: lightslategray;
}

The updated markup:

<x-siteheader>
  <header>
    <nav>
      <a href="home">Home</a>
      <a href="products">Products</a>      
      <a href="support">Support</a>
    </nav>
  </header>
  <x-siteheader-extras>
    <a href="cart">Cart <x-badge count="1"></x-badge></button>
    <a href="account">Account</a>
  </x-siteheader-extras>
</x-siteheader>

If or when your site header needs to be more sophisticated, ditch the required content and define a Custom Element and you can make it do anything:

Self-contained, handles everything, works everywhere!
<x-siteheader></x-siteheader>

That's 3 examples of how to apply the TAC CSS methodology. I hope it's inspired you to reconsider how powerful HTML is and how you can create true custom component APIs using just HTML, CSS, and JavaScript.

K
Kurt Rank1y ago

One other question: I notice in your modal dialog example you use slot. Is there ever a situation where it makes sense to use part instead of slot? Without a shadow DOM both of those won't do anything functionally but can be used for style selectors. I feel that slot makes sense for a lot of cases where you want to treat the inner content as optional or something that an author wants to place as a child of your custom element, but part makes sense if you have a nested element that is a "required" child element that isn't really considered as content being placed in the component.

I guess the downside is that styling would be confusing as you would do [part="something"] instead of ::part(something) like you would do with a real shadow DOM. I wonder if shadow DOM will be more widespread now that it can be declarative, albeit without shared templates yet.

J

Good question. Parts are elements that originate in the Web Component whereas slots are elements provided or "slotted in" by the developer (or by the app or however you want to think of it). In the case of the dialog, I want the developer to be able to provide the close button themself and wire up whatever logic or event handlers make sense for this particular dialog. They provided the button, I only defined a place for it to go, that's a slot.

However, there's some nuance here in the case of dialog, isn't there?

I technically probably shouldn't go for a slot in <dialog> because slots are for Web Components and <dialog> is a native element. I could just say "put a button in the dialog and wire it up", but that's just not enough for a good design system. I need to control a few details about that button, so I need some way of defining an API for it. Slots work well for this, so that's what I did. (there's some nuanced details that explain why, but too tired to type it all out right now) Although I'm using slots in a way that's not exactly technically correct. But then again the spec doesn't say not it and it works well for a number of reasons so I do it.

K
Kurt Rank1y ago

These are really great examples. I am a big fan of everything you've written about TAC, I am certainly inspired. I am incorporating this philosophy where possible on the things I work on. One potential issue I have come across is by not using classes you lose the flexibility to use different builtin elements for the main component wrapper. For example using an <article> for a card. I wonder if it makes sense to use custom attributes in more cases instead of a separate tag (like your ord button example -- as you can also apply it to a tags if someone wants a big call to action link). I've also been considering the discussion on registered custom attributes as an alternative to builtin extends with is="".

In the site-header example, would it instead make sense to do <x-siteheader role="header"> and then not need the inner <header>? What if someone wants to have a <x-card> but to show a blog excerpt where it may make sense to use <article>? I suppose you can nest it like article > x-card.

I am not sure what the right mix of custom tags, slots, attributes, classes, etc. is in some cases, when trying to build a library that may be used in ways I am not expecting. I don't want to confuse people either by having some components be tags, others as attributes or classes but I guess as long as it's clearly documented. Maybe I'm overthinking it, and if a <footer> or whatever is needed then it can just be nested. Or maybe I am still a bit too much in the mindset of how I used to build things with BEM haha

J

Thanks Kurt!

Let me clarify the most important thing first:

I am not sure what the right mix of custom tags, slots, attributes, classes, etc. is in some cases

and

I don't want to confuse people either by having some components be tags, others as attributes or classes

In all cases TAC says components MUST be defined with tags. Remember, the T is for tags. Tag comes first. Button starts with <button>, table starts with <table>, dialog starts with <dialog>, icon starts with <x-icon>, badge starts with <x-badge>.

So, as you've seen already a button would then of course start with <button>. Then your different kinds of buttons would be specified through an attribute. In all my years of doing this I've seen such an attribute named ord (ordinal, which has strong semantics if using "primary", "secondary", "tertiary" language) or kind or variant.

In the case of the site header example, I would agree <x-siteheader role="header"> is probably like the ideal application of the TAC methodology, but WCAG strongly discourages the use of roles (or any ARIA feature) whenever you're able to use the equivalent HTML feature. So in the case of a site header component defining a custom tag like x-siteheader and then defining its relationship with a child <header> I think is technically a bit more correct according to WCAG. But again, I would agree <x-siteheader role="header"> is better :)

Same goes for your x-card example: <x-card role="article"> seems great, but technically not the best way according to WCAG. Your second approach with a plain <article> and <x-card> inside I think is great too. Let article be an article, which is just a block-level element like div but with better semantics, then get the card styles and features from your actual card component.

One more example that might help: dialog. In one of the design systems that I've built we started with the raw <dialog> element (of course!) and simply styled it according to the company design language. In addition to just the plain dialog tag, we defined a slot for the dialog's basic close button (the [ X ] button positioned to the top-right corner) and a kind attribute that is used to set the dialog as a "drawer" which makes the dialog slide in and out like other similar drawer components. We also defined a rule for an optional <header> child that gets some minor styling and improves structure and semantics and similarly a <footer> which we said was required for the dialog's action button(s), like cancel and/or save buttons.

Hopefully that demonstrates how the component is always defined with a tag, then using the power of HTML primitives like attributes, slots, and parent and child and sibling relationships we can come up with some very capable components with really good API!

More from this blog

P

Poolside

33 posts

Writing about frontend things late at night in my chair by the pool.