Jekyll category filtering with no JS
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