DEV Community

Cover image for CSS only list filtering, or abusing the platform
Alex Vechy
Alex Vechy

Posted on • Updated on • Originally published at

CSS only list filtering, or abusing the platform

I did something bad and I’m excited to share it.

Self-inflicted problem (as many are)

I used to ship my personal website to Vercel, which has generous free plan. However, I always wanted to use Cloudflare, especially if I plan to buy the domain there.

As my website is just a bunch of static content, with only few interactive components, I chose Astro. And if I need to add something fancy, I won't have troubles adding it. For example, "Share" button is a Preact component (even if it didn’t have to be).

However, sometimes you really want the page to be rendered server side. In my case, /feed page is a list of all articles I published and the list can get quite lengthy (if I actually keep my promise to write more). For the readers to filter the topics they are interested in, I add a list of tags to each post, with all the topics listed on the /feed page:

A list of clickable tags, with some selected as filters for the list of articles

Each filter chip could be a link, adding a query param for filtering, like ?topic=web. Then, on the server, I would read the param and filter the feed:

/* Server only */
const topic = Astro.url.searchParams.get("topic");

// returns ALL articles
const feed = await getFeed();

const filteredFeed = topic
  ? feed.filter((post) =>
  : feed;

// create a flat list of all unique tags used
const topics = [ Set(feed.flatMap((post) =>];
Enter fullscreen mode Exit fullscreen mode

No client JavaScript, and thanks to View Transitions, visitors barely notice that the page was Server Side rendered.

Bringing solution to the problem that doesn’t exists; get two more problems

As I switched from Vercel to Cloudflare, I immediately noticed the major problem: my images are not optimised. astro:assets provide image optimisations using Sharp, and Cloudflare adapter does not support it (maybe only for now?). So, I was left with the choice of:

  • manually optimise images every time;
  • do not optimise images;
  • do not SSR, build the page at build time with all the content, apply filtering client side;

As first two options are not to my liking, I thought what I can do with the last one.

Obviously, I could just remove tags filtering and nobody would notice. But hey, we’re engineers here, and we like the challenge.

First quick solution is to add filtering through JavaScript, we all did that:

  • get a list of articles and tags as JSON from the server;
  • pass it to a component that keeps track of selected chips and renders a list;
  • react to chips selection to filter the list;

This seems like a trivial task for any web framework. With Svelte I could even avoid shipping lots of JavaScript. But hey, we’re engineers here, and we like the challenge.


I knew I can just render all articles and then hide those not matching filtering with display: none. I also knew I could toggle that with :has CSS selector. So, I need:

  • a list of checkboxes with a value corresponding to each tag
  <input type="checkbox" value="react" />
  <input type="checkbox" value="tailwindcss" />
Enter fullscreen mode Exit fullscreen mode
  • hide all articles when one of the checkboxes is checked (filtering applied)
/* if any of the checkboxes is selected, hide all articles */
.feed:has(> form input:checked) .feed-entry {
  display: none;
/* TODO: put display: block to those matching selected checkboxes */
Enter fullscreen mode Exit fullscreen mode
  • Show the articles matching the filter

Show the articles matching the filter

This last point is the most complex. As the list of tags and articles is dynamic, I couldn’t just list all of them in CSS, but I knew I need something like this:

.feed:has(> form input:where(
  /* list of all article tags */
 ) matching-article  { display: block; }
Enter fullscreen mode Exit fullscreen mode

I decided to go with a straightforward approach: generate <style> tag with each article rendered:

/* for each post, take it's slug as id */
const id = `entry-${post.slug}`;
/* form a CSS selector */
const css = /* CSS */ `
.feed:has( > form input:where(
    ${ => `[value="${tag}"]:checked`).join(",")})
) #${id} { display: block; }`;

return (
    <style set:html={css} />
      <a href={post.slug}>{post.title}</a>
Enter fullscreen mode Exit fullscreen mode

Now, each article would include both, the UI and the <style> tag which makes it visible when correct checkboxes are selected.

Browser Dev Panel with DOM tree, with list items having both, style and list item tags

P.S. Again, any Web framework would solve this better. You would ship less code in general, as instead of repeated HTML for each article, you only ship template for what JS should generate at runtime. Especially for this functionality, the risk of JS being disabled or failed to load can be neglected completely.

Resetting the state

As the visitors select multiple chips, we want them to be able to reset the selection to see all articles again. The solution is quite simple: reset input/button:

  /* style same way as the rest of the chips */
  <input type="reset" />
  <input type="checkbox" value="react" />
Enter fullscreen mode Exit fullscreen mode

An animation showing how the filter chips can be reset with input type reset


The CSS shipped with every article could be optimised, to ship less code. As the list is rendered fully at build time, I leverage lightning fast lightningcss:

import { transform } from "lightningcss";

// in each article
const { code } = transform({
  code: Buffer.from(/* CSS */ `
    .feed:has( > form
      .map((tag) => `[value="${tag}"]:checked`)
    )) #${id} { display: block; }
  minify: true,
return (
    <style set:html={code} />
Enter fullscreen mode Exit fullscreen mode

Would also be nice to add view transitions to smoothly animate list items appearing and disappearing. Something like:

Enter fullscreen mode Exit fullscreen mode

This doesn’t work, however, as the list items display is changed before the snapshot is taken? Not sure.


You can play around with the final result on this deployment:

As I said, it was a challenge rather than a final solution. What you should be doing instead?

  • ship JavaScript;
  • leverage the server. For example, with little bit of JavaScript, you can achieve lazy loading of the feed as user scrolls, instead of shipping huge HTML as is;
  • ship more rounded corners;

Top comments (5)

efpage profile image

The result is pretty confusing:

  • The button behavoir is not consistent. "All" shows all items, but hides the corresponding selection of buttons. So, the filter behaves, as if all buttons where selected, even if they are not.
  • You get no feedback, what you are doing. There should be a little hint: 7 items of 20 selected. This was pretty easy with some JS, but i suppose, there is no CSS solution for that...
  • if the server just serves what is visible, performance should not be an issue. Seems much easier with some client side JS managing the UI
alvechy profile image
Alex Vechy

I agree, these are good points 👍

efpage profile image

Referring to my own tests, the data handling with Javascript is pretty fast.
Usually it takes less than 1 ms to build the DOM elements, but it may take up to 500 ms to render the page. This depends greatly on page content, if you include some pictures in your list, it may take even longer. Browsers are highly optimized, so you might get similar results with CSS filtering, but bottomline, the total performance may depend on other factors, mainly on the load time.

Delivering some thousand elements might be one of this bottlenecks, regardless wich kind of filter you use. So, it would be best to render the page on the server and provide some lazy loading to deliver only the visible page content.

cicirello profile image
Vincent A. Cicirello

I like it. It obviously isn't scalable, but it's an interesting example of what you can do with CSS (even if whether you should do it might be another story).

philipjohnbasile profile image
Philip John Basile

This is really neat!