Packaging for Performance

An interesting topic of discussion in recent times has been around static resource (JS/CSS) packaging for web applications. Craig Silverstein’s and Rebecca Murphey’s write-ups on this topic provide great insights into the reality of packaging in today’s frontend engineering world. The main question that comes up is, should the strategy for JavaScript and CSS bundling (which is a current performance best practice) change when migrating to HTTP/2? Although in theory the benefit of bundling—which is to reduce the number of HTTP requests—becomes void with HTTP/2, reality is we are not there yet. The above articles are proof of that reality. At eBay we did similar research a few months back when prepping the site for HTTPS, and our findings were the same. In this post, I will outline the approach that we have taken towards packaging and the performance benefits of our approach.

The majority of eBay pages followed a naïve pattern for bundling resources. All the CSS and JavaScript for a page were bundled into once resource each, with the CSS included in the head tag and JS at the bottom. Though it was good in terms of reducing the number of HTTP requests, there were still opportunities to improve — the main one being a better use of browser caching. As users navigate through eBay pages, every unvisited page needs to download the whole JS and CSS, which includes the same core libraries (jQuery etc.) used in previous pages. With our plan towards moving to HTTPS (and HTTP/2), we knew this coarse-grained packaging would not be effective. Our study, and others’ studies, also indicated that avoiding bundling altogether and loading resources individually would still not be effective in terms of performance. We needed a balance, and that’s when we came up with our own packaging solution.


Our first step was to identify the core JS and CSS libraries used across all eBay pages and to aggregate them as one resource. To do this, we created an internal Node.js module called Inception. This module includes all the common JS and CSS modules and will be added as a dependent by each domain team (owners of the various eBay pages). The identified core JS libraries were jQuery, marko (templating engine), marko-widgets (UI component abstraction), and in-house analytics and tracking libraries. For CSS, we have our own library called Skin, from which we picked the core, button, icons, dialog, and form modules. The package bundler we use at eBay is Lasso. The Inception module, which plugs in along with Lasso, provides the following functionalities:

  • Enforces all domains (buying, selling, browse, checkout, etc.) to follow the exact version of the core JS and CSS libraries. Non-compliance will result in build failures.
  • Bundles the inception resources as one URL with the same URL address across all domains. Examples are inception-hashcode.js and inception-hashcode.css.
  • Enables domain teams to still include any of the Inception JS/CSS libraries as a part of their own module dependencies. The Lasso optimizer will de-dupe libraries and ensure only one copy is sent to the browser. This functionality is critical for two reasons . First, we want to promote module-level encapsulation, so that when domain teams are building modules they are free to add a dependency on a core library (say skin-button) without worrying about duplication. This also makes the module work standalone. Secondly, domain teams should not bear the overhead of tracking what is or isn’t present in Inception. They should be able to include any dependency they want, and the tooling can take care of the optimization.

Now with Inception in place, we started seeing these benefits:

  • Browser caching: One of the drawbacks mentioned earlier—bundling all resources as one URL—is poor leverage of browser caching. Inception fixes this drawback. Since the same URL is used across all domains for the core JS and CSS libraries (which BTW is the majority of the payload), browser caching is heavily utilized as users navigate through various eBay experiences. This caching is a massive improvement in terms of performance, especially for slow connections. In addition, with newer browser versions supporting code caching, we might also avoid the parse and compile times for the big Inception JS bundle.
  • Library consistency: Another problem that we saw in our previous bundling system was lack of consistency in core library versions used across domains. Since domains were maintaining the core libraries, users navigating from one domain to another might, for instance, get different versions of jQuery or button styles. The result is not only UI inconsistency but also implementation inconsistency across domains. This issue is also fixed with Inception, as it is a central place to manage core libraries.
  • Path to Progressive Web Apps: With all domain pages having the same core library dependencies, transition between them becomes easy, since only the application-specific JS and CSS has to be downloaded on each navigation. This approach will enable us to build our web apps using an Application Shell Architecture, thus paving the way to making eBay a Progressive Web App. We have already explored a similar route in the past (within a domain) using the Structured Page Fragments approach, and we have seen our perceived performance increase immensely.
  • Easy upgrade path: Finally, Inception also enables us to upgrade to newer versions of core libraries from a central place. Inception itself follows semantic versioning, so all domain teams depending on Inception will get the updates uniformly in a semantic manner. Upgrades were problematic previously, as we had to chase individual teams to do the upgrades manually.


Now with the core libraries being taken care of through Inception, what about the remaining resources in a page—i.e., application/domain-specific CSS and JS? For each domain we again came up with a packaging approach where we split the resources into two buckets:  constants and variables

  • Constants: The CSS and JS resources that remain the same on every request are bucketed as constants. These mainly pertain to the key UI components in each domain, which remain unaltered to various request parameters. The constant modules are bundled as one resource, and they again benefit from browser caching. When a user revisits a page, this bundle usually hits the browser cache and gets a performance advantage.
  • Variables: A small portion of the resources in each page vary based on request attributes. These variations might be due to experimentation, user sign-in status, business logic, etc. Such resources are bucketed as variables and have their own bundling, which happens at runtime. They will have the least cache-hit ratio and probably will need to be downloaded over the network for a new session.


To summarize, every page will have six resource bundles (3 for CSS and 3 for JS), and each bundle will have its own purpose. All URLs are hashed based on content; thus cache busting is automatically taken care of.

  1. Inception — bundles the core CSS and JS libraries. Highest in payload.
  2. Constants — bundles the unchanging application CSS and JS. Mediacore in payload.
  3. Variables — bundles the varying CSS and JS in an application. Least in payload.

In the current state, this packaging strategy seems to be the best fit for us in terms of performance. It has created the right balance between number of HTTP requests and browser caching. As we start migrating towards HTTP/2 next year, we will further evaluate this approach and try to come up with more fine-grained bundling solutions, and of course with performance being the key.

Announcing Pulsar Reporting: Near-Real-Time Metrics Reporting Framework

We are excited to announce the first open-source release of Pulsar Reporting.

Earlier this year, we announced, an open-source project that included Pulsar Pipeline, a real-time analytics platform and stream processing framework. One of the frequently requested features for Pulsar has been integration with a metrics store for visualizing the near-real-time metrics. We’ve provided this feature with this release, which adds the Pulsar Reporting API and the Pulsar Reporting UI Framework under the same license terms. The public GitHub repository is

What is Pulsar Reporting?

Pulsar Reporting is an extensible data visualization and reporting framework designed to provide real-time insights from Pulsar Pipeline. The framework includes a rich set of charting widgets and a visual reporting editor for users to easily create reports. It has a robust data query engine that can be extended to support many different types of data sources. With the Pulsar Reporting Framework, users can quickly create multi-dimensional and interactive reports that include drill-down and slice-and-dice capabilities.


  • Near-real-time reports – Building reports based on near-real-time data that auto-refreshes at specified intervals
  • Visual reporting editor – Generating reports without writing any code
  • Rich charting widgets – Creating multiple chart types:   line, bar, histogram, pie, stack,  datatable, etc.
  • Reporting API – Querying data with human-friendly SQL or program-friendly structured JSON
  • Dynamic data source management – Adding or removing data sources with no down time
  • Security and permissions – Managing authentication and access control
  • Druid Kafka extension – Ingesting real-time data from Kafka into Druid
  • AngularJS-based hierarchical UI framework – Easily adding and extending reports
  • Bootstrap-based responsive design – Being able to use Pulsar Reporting on different sizes of screens

Why Pulsar Reporting?

The Pulsar Reporting Framework complements Pulsar, an open-source, real-time analytics platform and stream processing framework. Pulsar generates huge amounts of data, and visualization is the best way to provide intuitive and meaningful insights into that data. However, building dashboards and reports for big data from scratch is cumbersome and error-prone. The Pulsar Reporting Framework allows user to create reports easily and quickly without requiring complex data processing and UI logic.


The raw events and session events from Pulsar Pipeline flow to Kafka using the Pulsar Kafka channel. The Druid cluster then ingests the raw events as well as the sessions from Kafka topics into two tables, one for sessions and one for events. Both tables are indexed in one-second granularity to enable real-time reporting. The Pulsar Reporting API provides an abstract layer to access the tables. The Reporting UI gets the data from the API to build different charts.


Sample API requests

    • Get session metrics using the SQL API:
      Endpoint: http://<API_Server>/prapi/v2/sql
      Method: POST
      Body: {"sql" : "SELECT (count(session) - sum(retvisitor)) * 1.0 / count(session) newSessionRate, sum(sessionDuration) * 1000 totalSessionDurations, count(session) sessions, sum(sessionDuration) totalSessions, sum(totalpagect) totalPages, country, trafficSource FROM pulsar_session WHERE site=0 and country='usa' GROUP BY country, trafficSource ORDER BY sum(totalpagect) ASC limit 20",
      "intervals": "2015-10-11 03:00:32/2015-10-18 01:00:32",
      "granularity": "day"}
    • Get page views by traffic source using the structured JSON API:
      Endpoint: http://<API_Server>/prapi/v2/realtime
      Method: POST
      Body: {"metrics" : [ "pageviews" ], "dimensions" : [ "trafficsource" ], "filter" : "site=0" }

What’s next?

We have open-sourced the Pulsar Reporting Framework, and we plan to continue developing the code in the open. We welcome your suggestions and contributions. Here are some of the features we are thinking about.

  • Pathing and funnels
  • Exporting reports
  • Expanding support to additional data sources based on community interest
  • Integrating with Pulsar.js, a client-side Javascript library to generate Pulsar events for the web

Please visit for source code, documentation, and more information.

The Team


How Our CSS Framework Helps Enforce Accessibility


Screenshot of two visually identical 'Buy it Now' buttons

Spot the difference….You can’t! To a sighted user it appears we have two identical button elements.

A user interface control not only needs to look like a certain control, it must be described as that control too. Take for example a button, one of the simplest of controls. There are many ways you can create something that looks like a button, but unless you use the actual button tag (or button role – more on roles later), it will not be described as a button.

Why does it need to be described as a button? Users of AT (assistive technology), such as a screen reader, may not be able to see what the control looks like visually; therefore it is the job of the screen reader to describe it aurally. A screen reader, such as VoiceOver for Mac OSX and iOS, can do this job only if we, the developers, ensure the correct semantics are present in our HTML code.


In the table below, compare and contrast the accessibility tree attributes for each element  (hint: click each image to view at full size). VoiceOver uses the accessibility tree to convey to the user what it knows about the web page. You will see that for the fake button, there is nothing in the tree to identify the span element as a button. Quite simply, VoiceOver does not know this element is intended to be a button.

Now spot the difference: this is how VoiceOver sees these two elements
‘REAL’ button ‘FAKE’ button
HTML <button class="btn">Buy it Now</button> <span class="btn">Buy it Now</span>
ACCESSIBILITY TREE ATTRIBUTES Annotated accessibility tree of real button Annotated accessibility tree of fake button
VOICEOVER “Buy it now, Button.”

“To click this button press CTRL-OPTION-SPACE.”

“Buy it now.”

Accessibility tree screenshots taken from Mac OSX Accessibility Inspector

What’s also interesting is that if you look at the ‘Actions’ section of the tree, the real button has an ‘accessibilityPerformPress’ action, while the fake button does not. Armed with this information, VoiceOver can also describe how to interact with the element (e.g., press CTRL-OPTION-SPACE). No such information will be communicated for the fake button.

We can safely say that this fake button is not accessible, because the AT doesn’t know what it is or how to interact with it. It appears our fake button is accessible only to people who can see the screen and use a mouse. Oh dear – this fake button has excluded a large number of our users from being able to buy items!

Swiss cheese

You might be wondering, “Who on earth would use a span or div tag for a button?”

You might now also be thinking, “What on earth does Swiss cheese have to do with any of this?”

In the Swiss cheese model of accident causation, risk of a threat becoming a reality is mitigated by differing layers and types of defenses that are “layered” behind each other. For example, we might use code linting, code reviews, accessibility checkers, and manual testing to help ensure that this button is properly described. We liken these separate layers to multiple slices of Swiss cheese, stacked side by side – hence the name.

Illustration of swiss cheese model

Is there anything cheese can’t do? Although many layers of defense lie between hazards and accidents, there are flaws in each layer that, if aligned, can allow the accident to occur.

What if we could also write our CSS framework in a way that acts as another layer in our line of defense? Read on to find out how!

Enforcing roles

Continuing on from our previous ‘fake button’ example, let’s suppose the developer had created the following rules to make the span element appear visually like a button:

.btn {
  background-color: #0654ba;
  border-radius: 0.25em;
  color: white;
  padding: 0.25em 1em;

Screenshot of fake 'Buy it Now' button

The dreaded fake button (although you still can’t tell, just by looking at it)

What we have here is the proverbial cart before the horse. The developer has styled the element before describing its purpose. One way in which we can create the necessary description (the horse) is to require a role attribute. We’ll go into more detail on the role attribute later, but here’s the interesting bit – we can leverage attribute selectors and re-write our CSS like so:

[role=button].btn {
  background-color: #0654ba;
  border-radius: 0.25em;
  color: white;
  padding: 0.25em 1em;

Screenshot of unstyled 'Buy it Now' span element

Under the skin: our attribute selector has now exposed the fake button for the fraud that it is!

Our selector now ensures that a button will visually appear like a button only if it has first been described as a button. You can almost think of this as TDD (test-driven development). If the HTML does not pass our ‘test’, the visual style will not be applied.

Implicit roles

It’s important to know that nearly all elements have a default implicit role, and these default roles do not need to be specified in the HTML – to do so would be redundant. No prizes for guessing what the default role of a button element is. Yes, it’s button!

You might think that it was easy enough for us to convert a span into an accessible button using the button role, but in actual fact our work would not be finished there. Adding a role does not add behavior. A fully accessible button must be keyboard focusable and it must be invokable with SPACE and ENTER keys too. A button element gives this behavior for free; a span element – even with a role of button – does not, and we must implement its behavior by hand.

So please, and I really can’t emphasize this strongly enough, do everybody a favor and always use an actual button element for buttons.

The only real reason you might have for using the button role is when progressively enhancing a link into a button using JavaScript; for example, to make the link open an overlay instead of a new page – which is exactly what we do on eBay. As with spans and divs, allowing anchor tags for buttons does re-open the door to misuse and abuse (think ‘faux’ buttons); and though it is possible to enforce the correct usage with clever use of attribute selectors, it’s a little more convoluted and therefore beyond the scope of this post.

Again, we can enforce this markup requirement by rewriting our CSS selector like so:

button.btn {
  background-color: #0654ba;
  border-radius: 0.25em;
  color: white;
  padding: 0.25em 1em;

Screenshot of our final 'Buy it Now' button

Horse, cart, & driver: the element now has the appearance, description, and interaction of a button

Finally, no more span and div tags for buttons. Our CSS framework simply does not allow it.

Enforcing states

So far we’ve looked at a simple example of how CSS selectors can force developers to put the proper semantics in place – whether implicitly or explicitly. But what about state? If an element has state (a checked checkbox for example), it is not sufficient to describe only what the element is; we must also describe what state it is in.

Developers often fall into exactly the same trap as before: they convey the state visually but not aurally.

In the following code example, the developer has used a modifier class of btn--disabled in order to alter the opacity and background-color of the button:

button.btn--disabled {
  background-color: #999;
  opacity: 0.5;

Screenshot of a button that appears visually disabled

Our ‘ghosted out’ button appears visually disabled

Modifier class is a BEM (Block, Element, Modifier) concept. Throughout this article we will be using a variation of BEM in order to structure and distinguish our class names.

You might be thinking that this isn’t really disabled. If so, you are quite right. This button will not be described as disabled and it will not behave as disabled.

Again, you might be thinking, “Who actually does this kind of stuff?”, but fear not, our CSS selectors can again protect us from this manner of profanity:

button[disabled] {
  background-color: #999;
  opacity: 0.5;

As you can see, the previous modifier class will no longer cut the mustard. It is removed from the selector entirely and the HTML disabled property takes its place. Only when this property is applied in the markup will the button be well and truly disabled for all users.

Comparing accessibility trees we see that the button with class name is still described as ‘Enabled’
Disabled property Disabled class
Annotated accessibility tree of button with disabled property Annotated accessibility tree of button with disabled classname

So far, none of this is particularly earth-shattering, I’m sure you agree, but it sets the stage nicely for moving onto more complex controls and widgets, where we must start delving deeper into the world of WAI-ARIA (commonly referred to as just ARIA for short).


HTML gives us only a limited set of controls such as buttons, links, and the various form value inputs. What about menus, tabs, carousels, overlays, etc. – how do we describe those? Yes, you guessed it – ARIA comes to our rescue.

ARIA gives us many more roles beyond a simple button, and these roles, in conjunction with a multitude of states and properties, open up a whole new set of desktop-like user interface controls and widgets for us to play with. Just make sure you read the instructions before diving in. You do read the instructions don’t you?

Look out for more controls in HTML5, such as menu and dialog. In fact, you might be interested to know that both the menu and dialog tags started out life as ARIA roles before they were introduced as bona fide HTML elements. Don’t get too excited, though – neither have cross browser support at the time of this writing.

In the next section we will look at an example of such a widget and demonstrate how we can use ARIA to influence the way we write CSS selectors in order to enforce accessible markup.


A tabs widget allows the layered stacking of two or more content panels, whereby only one panel of content can be visible at any time. A list of clickable tabs allows the user to swap out the visible panel. This all happens on the client, without a full page reload (i.e., the client is stateful). By decluttering the user interface in this way we can say that a tabs widget follows the principle of progressive disclosure.

Screenshot of eBay's tabbed interface for sign-in or register

Using tabs, the user can switch between “Sign In” or “Register” without a full page reload.

It is critical that our interface is not only visually identifiable as a tabs control (I’ve seen designs that struggle even to meet this criterion!), but also aurally. Without any tab-related HTML tags, how do we achieve this?

Faux tabs

A seasoned developer might set out initially to create the tabs as a list of clickable page anchors for the tabs, with a group of divs acting as anchor targets for the tab panels:

<div class="tabs">
    <li class="tabs__tab tabs__tab--selected">
      <a href="#sign-in">Sign in</a>
    <li class="tabs__tab">
      <a href="#register">Register</a>
    <div class="tabs__panel tabs__panel--active" id="sign-in">
      <!-- Sign in Content -->
    <div class="tabs__panel" id="register">
      <!-- Register Content -->

This is a perfectly reasonable approach to begin with. Page anchors are often well suited as the starting point for tabs, because in the case of JavaScript being unavailable they ensure at least some basic functionality when clicked (i.e., the browser will scroll to the content of the relevant panel). However, when JavaScript does become available, care must be taken to prevent the default link behavior so as to not interfere with tab semantics and behavior. Let me be very clear about this: links are not the same as tabs!

This technique of making core content and functionality available pre-CSS and pre-JavaScript is called progressive enhancement. Progressive enhancement is the safest and surest way to guard against the unknown (e.g., script timeout, script failure, scripting disabled) and to ensure your core experience remains backwards and forwards compatible in all HTML-capable browsers.

We will assume that all layout-related styling is in place for the links (i.e., they are neatly spaced out horizontally), and that by default the visible state of all panels is hidden, with only the ‘active’ panel displayed. Let’s then suppose our developer chooses to visually convey the selected ‘tab’ state using only an underline (a veritable tour de force of minimalism, I know):

.tabs__tab {
  text-decoration: none;
.tabs__tab--selected {
  text-decoration: underline;
.tabs__panel {
  display: none;
.tabs__panel--active {
  display: block;

It would now take only a small amount of JavaScript for our developer to turn this into a “functioning” tabs widget by preventing the default link action (i.e., prevent it navigating to the URL fragment) and toggling the ‘selected’ and ‘active’ modifier classes accordingly; and indeed our developer might be tempted to stop there.

But although this control looks like a tabs widget, it will currently be described only as a list of links (scroll down to see the accessibility tree). No clues are given as to the dynamic, stateful nature of the widget. Screen reader users attempting to follow one of these links are going to be surprised when nothing happens after invoking the link, and equally surprised when no navigation occurs. They are left guessing as to what type of control they might be interacting with. Not a good experience.

Let’s fix it so that if developers try to use our amazingly awesome CSS to style their tabs like ours (go on, admit it, you want that underline too), the styles will appear only if they have the correct accessible markup in place.

Real tabs

To achieve the correct markup for tabs, just as with our simple button example, we can replace class names with ARIA roles and states.

Luckily, ARIA gives us a set of tab-related roles:

  • tablist
  • tab
  • tabpanel

We can also leverage the following global ARIA states:

  • aria-selected
  • aria-hidden
  • aria-controls
  • aria-labelledby

Whilst it would be entirely possible to continue on with our demonstration of progressive enhancement by applying the above roles and states to override our previous link-based markup, it does add some additional complexities which might distract us from the primary topic at hand. So, rather than getting bogged down in those details, let’s drop the progressive enhancement for now and pretend we live in a magical world where JavaScript is always on, is always available, and always works.

Actually, to be honest, it’s not just a JavaScript issue. Some people would argue that by using list-based markup, we also provide for a reasonable semantic fallback in the cases where the tab & tablist roles are not supported by the user’s browser & AT combo.

It will make most sense if we show you the new HTML first this time, rather than the CSS, and hopefully, without the cognitive clutter of the list and link tags, our end goal is now a little clearer. You will quickly see that the core DOM structure remains almost identical:

<div class="tabs">
  <div role="tablist">
    <div role="tab" aria-selected="true" tabindex="0">
      <span>Sign in</span>
    <div role="tab" aria-selected="false" tabindex="-1">
    <div role="tabpanel" id="sign-in" aria-hidden="false">
      <!-- Sign in Content -->
    <div role="tabpanel" id="register" aria-hidden="true">
      <!-- Register Content -->

With these new ARIA roles in place, our tabs will now actually be described as tabs by assistive technology. Likewise, when our JavaScript toggles the ARIA selected state, this state will also be conveyed to our users.

Note that AT actually requires two additional ARIA properties that are not present in our markup: aria-controls (on the tabs) and aria-labelledby (on the tabpanels). These ARIA properties are not typically used as styling hooks on tabs, so we will leave them out for the sake of code brevity; but be sure to include them when building your own tabs widget!

Okay, so we are nearing the end now, but first we must finish up our CSS. Our selectors must become a contract for the accessible HTML above. Where before we had classes for BEM blocks and elements, now we have ARIA roles. Where before we had classes for BEM modifiers, now we have ARIA states:

.tabs [role=tab][aria-selected=false][tabindex="-1"] {
  text-decoration: none;
.tabs [role=tab][aria-selected=true][tabindex="0"] {
  text-decoration: underline;
.tabs [role=tabpanel][aria-hidden=true] {
  display: none;
.tabs [role=tabpanel][aria-hidden=false] {
  display: block;

Personally, I’m a big fan of BEM, but it’s nice where possible like this to be able to replace it with something a little more real, if you know what I mean.

Finally, let us compare the accessibility tree of the first real tab with the first faux tab
Real tab Faux tab
Annotated accessibility tree of real tabs Annotated accessibility tree of faux tabs

One other rule we have enforced in our selectors is the tabindex attribute. Keyboard accessibility for tabs must be implemented in JavaScript using a roving tabindex technique; this is because the tabs in a tablist are selected using the arrow keys, not the tab key (the tab key is actually used to exit the list of tabs). While not strictly necessary to ensure the correct description is given, this selector helps ensure that the correct attribute values are in place for roving tabindex behavior. It’s up to you whether you want to go this far, into the realm of behavior-testing, in your own selectors.

Good behavior

We must always remember that correctly describing a UI control is only part of making it accessible. The user expectation is that it behaves like that control too. Therefore we must also ensure that the correct accessible behavior is in place.

For example, a button must always be ‘clickable’ with SPACE and ENTER keys. Sadly, this kind of behavior is often the first thing to go missing when developers try rolling their own buttons using span or div tags.

More complex controls such as tabs, menus, or autocomplete will typically require a more significant amount of JavaScript in order to make sure the control fully behaves according to its description.


We have seen that each layer of the web frontend has its own responsibilities in terms of creating accessible UI controls:

  • HTML provides the aural description and some built-in behavior
  • CSS provides the visual style and interaction clues
  • JS provides any missing behavior not provided by ARIA or HTML

HTML provides behavior, without the need for JavaScript, for built-in tags such as links, buttons, and form controls.

For the purpose of this blog post, our focus has been primarily HTML and CSS. HTML is fundamental in laying solid foundations for accessible UI controls and widgets, and we have shown how those foundations can be enforced by use of CSS attribute selectors.

So, the next time you find yourself creating a class name like ‘active’, ‘hidden’, ‘on’, or ‘off’ – stop and think instead how you might be able to leverage HTML properties or ARIA states in your selectors. Likewise, if you find yourself creating a class name like ‘btn’, ‘tab’, or ‘dialog’ – also stop and think about how you might be able to leverage an existing HTML tag or ARIA role.

Thank you for reading. I hope you enjoyed it. If you are interested in more accessibility-related articles in future, be sure to leave a comment below!

Finally, if you are interested in learning more about our CSS framework, watch this space for an upcoming announcement and further details. We are currently applying the finishing touches to the framework before releasing it as open source.

Appendix / bibliography