The number of posts on here is starting to build up, so I figured it was time to add filterable categories. But I’ve mostly built this site with no JavascriptExcept in posts where necessary for projects like the gradient generator, and for the Substack embed. and I wanted to maintain that.

I also didn’t want each category click to be a new pageload. So the question became: can you make a nice filter interaction with just CSS & Jekyll’s liquid formatting?

Answer: yes! Check the homepage :)

Here’s a quick breakdown for anyone doing the same…


The basic structure:

  • use styled radio buttons with :checked CSS selectors
  • make a Jekyll plugin to find and generate all the CSS rules at build time — rules are needed for each category
  • add CSS classes / data attributes that specify the categories on each post line on the homepage
  • when the relevant button is selected, hide all the posts that don’t have the corresponding category attributes

First, the category selector in _layouts/home.html:

<input type="radio" id="filter-all" name="category-filter" value="all" checked>
<input type="radio" id="filter-best-of" name="category-filter" value="best-of">

{%- comment -%}Collect all categories, handling both single values and arrays{%- endcomment -%}
{%- assign all_categories = "" | split: "" -%}
{%- for post in site.posts -%}
    {%- if post.category -%}
    {%- if post.category.first -%}
        {%- comment -%}Category is an array{%- endcomment -%}
        {%- for cat in post.category -%}
        {%- assign all_categories = all_categories | push: cat -%}
        {%- endfor -%}
    {%- else -%}
        {%- comment -%}Category is a single value{%- endcomment -%}
        {%- assign all_categories = all_categories | push: post.category -%}
    {%- endif -%}
    {%- endif -%}
{%- endfor -%}
{%- assign categories = all_categories | uniq | compact | sort -%}

{%- for category in categories -%}
<input type="radio" id="filter-{{ category | slugify }}" name="category-filter" value="{{ category | slugify }}">
{%- endfor -%}

<div class="category-filter">
    <span class="category-filter-title">Topics:</span>
    <div class="category-filter-options">
    <label for="filter-all" class="category-filter-label">All</label>
    <label for="filter-best-of" class="category-filter-label">Best-of</label>
    
    {%- for category in categories -%}
    <label for="filter-{{ category | slugify }}" class="category-filter-label">{{ category }}</label>
    {%- endfor -%}
    </div>
</div>

Then, the post list and CSS class assigner, also in _layouts/home.html:

    <ul class="post-list home-article-section">
    {%- assign sorted_posts = site.posts | sort_by_effective_date -%}
    {%- for post in sorted_posts -%}
    {%- comment -%}Build category data attribute for this post{%- endcomment -%}
    {%- assign category_list = "" | split: "" -%}
    {%- if post.category -%}
      {%- if post.category.first -%}
        {%- comment -%}Category is an array{%- endcomment -%}
        {%- for cat in post.category -%}
          {%- assign slugified_cat = cat | slugify -%}
          {%- assign category_list = category_list | push: slugified_cat -%}
        {%- endfor -%}
      {%- else -%}
        {%- comment -%}Category is a single value{%- endcomment -%}
        {%- assign slugified_cat = post.category | slugify -%}
        {%- assign category_list = category_list | push: slugified_cat -%}
      {%- endif -%}
    {%- else -%}
      {%- assign category_list = category_list | push: "uncategorized" -%}
    {%- endif -%}
    {%- if post.emoji -%}
      {%- assign category_list = category_list | push: "best-of" -%}
    {%- endif -%}
    {%- assign post_categories = category_list | join: " " -%}
    <li class="post-item" data-categories="{{ post_categories }}">

Then in post frontmatter, any of these work:

category: [Tech, Philosophy]
category: Tech
emoji: đź§Ş 

(the presence of emoji sets a post as “best-of,” and on the homepage prepends that emoji and makes the title bold)

Here’s the category filter CSS generator plugin:

module Jekyll
  class CategoryFilterGenerator < Generator
    safe true
    priority :lowest

    def generate(site)
      # Collect all categories from posts
      all_categories = []
      
      site.posts.docs.each do |post|
        if post.data['category']
          if post.data['category'].is_a?(Array)
            # Category is an array
            post.data['category'].each do |cat|
              all_categories << cat
            end
          else
            # Category is a single value
            all_categories << post.data['category']
          end
        end
      end
      
      # Add special categories
      all_categories << 'best-of'
      all_categories << 'uncategorized'
      
      # Get unique categories and sort them
      categories = all_categories.uniq.compact.sort
      
      # Generate CSS content
      css_content = generate_css(categories)
      
      # Create a CSS file in the assets directory
      css_file = PageWithoutAFile.new(site, site.source, 'assets/css', 'generated-category-filters.css')
      css_file.content = css_content
      css_file.data['layout'] = nil
      
      site.pages << css_file
    end

    private

    def generate_css(categories)
      css = <<~CSS
        /* Automatically generated category filtering CSS */
        /* Generated at build time based on actual post categories */
        
        /* Category Filtering Logic */
        /* Show all posts by default */
        .post-item {
          display: list-item;
        }
        
        /* Show all when "All" is selected (default) */
        #filter-all:checked ~ .home-article-section .post-item {
          display: list-item;
        }
        
        /* Checked state styling for category filter labels */
        #{generate_checked_state_rules(categories)}
        
        /* Hide all posts when any specific filter is active */
        #{generate_hide_rules(categories)}
        
        /* Show posts that match specific categories */
        #{generate_show_rules(categories)}
        
        /* Hide dividers when filtering */
        #{generate_divider_hide_rules(categories)}
        
        /* Add pseudo-element dividers when filtering */
        #{generate_divider_show_rules(categories)}
        
        /* Hide last divider */
        #{generate_divider_last_rules(categories)}
      CSS
      
      css
    end

    def slugify(text)
      text.to_s.downcase.gsub(/[^a-z0-9\-_]/, '-').gsub(/-+/, '-').gsub(/^-|-$/, '')
    end

    def generate_hide_rules(categories)
      selectors = categories.map do |category|
        slugified = slugify(category)
        "#filter-#{slugified}:checked ~ .home-article-section .post-item"
      end
      
      "#{selectors.join(",\n")} {\n  display: none;\n}"
    end

    def generate_show_rules(categories)
      selectors = categories.map do |category|
        slugified = slugify(category)
        "#filter-#{slugified}:checked ~ .home-article-section .post-item[data-categories*=\"#{slugified}\"]"
      end
      
      "#{selectors.join(",\n")} {\n  display: list-item !important;\n}"
    end

    def generate_divider_hide_rules(categories)
      selectors = categories.map do |category|
        slugified = slugify(category)
        "#filter-#{slugified}:checked ~ .home-article-section .home-article-divider"
      end
      
      "#{selectors.join(",\n")} {\n  display: none;\n}"
    end

    def generate_divider_show_rules(categories)
      selectors = categories.map do |category|
        slugified = slugify(category)
        "#filter-#{slugified}:checked ~ .home-article-section .post-item[data-categories]::after"
      end
      
      css = <<~CSS
        #{selectors.join(",\n")} {
          content: '';
          display: block;
          border: 0;
          height: 1px;
          color: #E6E4D9;
          background-color: #E6E4D9;
          margin: 15px auto;
          width: 85%;
        }
      CSS
      
      css.strip
    end

    def generate_divider_last_rules(categories)
      selectors = categories.map do |category|
        slugified = slugify(category)
        "#filter-#{slugified}:checked ~ .home-article-section .post-item[data-categories]:last-of-type::after"
      end
      
      "#{selectors.join(",\n")} {\n  display: none;\n}"
    end

    def generate_checked_state_rules(categories)
      # Add 'all' to the categories for the checked state styling
      all_filters = ['all'] + categories
      
      selectors = all_filters.map do |category|
        slugified = slugify(category)
        "#filter-#{slugified}:checked ~ .category-filter label[for=\"filter-#{slugified}\"]"
      end
      
      css = <<~CSS
        #{selectors.join(",\n")} {
          background-color: #575653;
          border-color: #575653;
          color: #FEFEFE;
          font-weight: 500;
        }
      CSS
      
      css.strip
    end
  end
end 

And then I just toss this in _includes/head.html:

<link rel="stylesheet" href="/assets/css/generated-category-filters.css">

Looking for more to read?

Want to hear about new essays? Subscribe to my roughly-monthly newsletter recapping my recent writing and things I'm enjoying:

And I'd love to hear from you directly: andy@andybromberg.com