Tag: Solution

A Lightweight Masonry Solution

Back in May, I learned about Firefox adding masonry to CSS grid. Masonry layouts are something I’ve been wanting to do on my own from scratch for a very long time, but have never known where to start. So, naturally, I checked the demo and then I had a lightbulb moment when I understood how this new proposed CSS feature works.

Support is obviously limited to Firefox for now (and, even there, only behind a flag), but it still offered me enough of a starting point for a JavaScript implementation that would cover browsers that currently lack support.

The way Firefox implements masonry in CSS is by setting either grid-template-rows (as in the example) or grid-template-columns to a value of masonry.

My approach was to use this for supporting browsers (which, again, means just Firefox for now) and create a JavaScript fallback for the rest. Let’s look at how this works using the particular case of an image grid.

First, enable the flag

In order to do this, we go to about:config in Firefox and search for “masonry.” This brings up the layout.css.grid-template-masonry-value.enabled flag, which we enable by double clicking its value from false (the default) to true.

Screenshot showing the masonry flag being enabled according to the instructions above.
Making sure we can test this feature.

Let’s start with some markup

The HTML structure looks something like this:

<section class="grid--masonry">   <img src="black_cat.jpg" alt="black cat" />   <!-- more such images following --> </section>

Now, let’s apply some styles

The first thing we do is make the top-level element a CSS grid container. Next, we define a maximum width for our images, let’s say 10em. We also want these images to shrink to whatever space is available for the grid’s content-box if the viewport becomes too narrow to accommodate for a single 10em column grid, so the value we actually set is Min(10em, 100%). Since responsivity is important these days, we don’t bother with a fixed number of columns, but instead auto-fit as many columns of this width as we can:

$ w: Min(10em, 100%);  .grid--masonry {   display: grid;   grid-template-columns: repeat(auto-fit, $ w); 	   > * { width: $ w; } }

Note that we’ve used Min() and not min() in order to avoid a Sass conflict.

Well, that’s a grid!

Not a very pretty one though, so let’s force its content to be in the middle horizontally, then add a grid-gap and padding that are both equal to a spacing value ($ s). We also set a background to make it easier on the eyes.

$ s: .5em;  /* masonry grid styles */ .grid--masonry {   /* same styles as before */   justify-content: center;   grid-gap: $ s;   padding: $ s }  /* prettifying styles */ html { background: #555 }

Having prettified the grid a bit, we turn to doing the same for the grid items, which are the images. Let’s apply a filter so they all look a bit more uniform, while giving a little additional flair with slightly rounded corners and a box-shadow.

img {   border-radius: 4px;   box-shadow: 2px 2px 5px rgba(#000, .7);   filter: sepia(1); }

The only thing we need to do now for browsers that support masonry is to declare it:

.grid--masonry {   /* same styles as before */   grid-template-rows: masonry; }

While this won’t work in most browsers, it produces the desired result in Firefox with the flag enabled as explained earlier.

Screenshot showing the masonry result in Firefox alongside DevTools where we can see what's under the hood.
grid-template-rows: masonry working in Firefox with the flag enabled (Demo).

But what about the other browsers? That’s where we need a…

JavaScript fallback

In order to be economical with the JavaScript the browser has to run, we first check if there are any .grid--masonry elements on that page and whether the browser has understood and applied the masonry value for grid-template-rows. Note that this is a generic approach that assumes we may have multiple such grids on a page.

let grids = [...document.querySelectorAll('.grid--masonry')];  if(grids.length && getComputedStyle(grids[0]).gridTemplateRows !== 'masonry') {   console.log('boo, masonry not supported 😭') } else console.log('yay, do nothing!')
Screenshot showing how Firefox with the flag enabled as explained above logs 'yay, do nothing!', while other browsers log 'boo, masonry not supported'.
Support test (live).

If the new masonry feature is not supported, we then get the row-gap and the grid items for every masonry grid, then set a number of columns (which is initially 0 for each grid).

let grids = [...document.querySelectorAll('.grid--masonry')];  if(grids.length && getComputedStyle(grids[0]).gridTemplateRows !== 'masonry') {   grids = grids.map(grid => ({     _el: grid,      gap: parseFloat(getComputedStyle(grid).gridRowGap),      items: [...grid.childNodes].filter(c => c.nodeType === 1),      ncol: 0   }));      grids.forEach(grid => console.log(`grid items: $ {grid.items.length}; grid gap: $ {grid.gap}px`)) }

Note that we need to make sure the child nodes are element nodes (which means they have a nodeType of 1). Otherwise, we can end up with text nodes consisting of carriage returns in the array of items.

Screenshot showing the number of items and the row-gap logged in the console.
Checking we got the correct number of items and gap (live).

Before proceeding further, we have to ensure the page has loaded and the elements aren’t still moving around. Once we’ve handled that, we take each grid and read its current number of columns. If this is different from the value we already have, then we update the old value and rearrange the grid items.

if(grids.length && getComputedStyle(grids[0]).gridTemplateRows !== 'masonry') {   grids = grids.map(/* same as before */); 	   function layout() {     grids.forEach(grid => {       /* get the post-resize/ load number of columns */       let ncol = getComputedStyle(grid._el).gridTemplateColumns.split(' ').length;        if(grid.ncol !== ncol) {         grid.ncol = ncol;         console.log('rearrange grid items')       }     });   } 	   addEventListener('load', e => {		     layout(); /* initial load */     addEventListener('resize', layout, false)   }, false); }

Note that calling the layout() function is something we need to do both on the initial load and on resize.

Screenshot showing the message we get when relayout is necessry.
When we need to rearrange grid items (live).

To rearrange the grid items, the first step is to remove the top margin on all of them (this may have been set to a non-zero value to achieve the masonry effect before the current resize).

If the viewport is narrow enough that we only have one column, we’re done!

Otherwise, we skip the first ncol items and we loop through the rest. For each item considered, we compute the position of the bottom edge of the item above and the current position of its top edge. This allows us to compute how much we need to move it vertically such that its top edge is one grid gap below the bottom edge of the item above.

/* if the number of columns has changed */ if(grid.ncol !== ncol) {   /* update number of columns */   grid.ncol = ncol;    /* revert to initial positioning, no margin */   grid.items.forEach(c => c.style.removeProperty('margin-top'));    /* if we have more than one column */   if(grid.ncol > 1) {     grid.items.slice(ncol).forEach((c, i) => {       let prev_fin = grid.items[i].getBoundingClientRect().bottom /* bottom edge of item above */,            curr_ini = c.getBoundingClientRect().top /* top edge of current item */; 						       c.style.marginTop = `$ {prev_fin + grid.gap - curr_ini}px`     })   } }

We now have a working, cross-browser solution!

A couple of minor improvements

A more realistic structure

In a real world scenario, we’re more likely to have each image wrapped in a link to its full size so that the big image opens in a lightbox (or we navigate to it as a fallback).

<section class='grid--masonry'>   <a href='black_cat_large.jpg'>     <img src='black_cat_small.jpg' alt='black cat'/>   </a>   <!-- and so on, more thumbnails following the first --> </section>

This means we also need to alter the CSS a bit. While we don’t need to explicitly set a width on the grid items anymore — as they’re now links — we do need to set align-self: start on them because, unlike images, they stretch to cover the entire row height by default, which will throw off our algorithm.

.grid--masonry > * { align-self: start; }  img {   display: block; /* avoid weird extra space at the bottom */   width: 100%;   /* same styles as before */ }

Making the first element stretch across the grid

We can also make the first item stretch horizontally across the entire grid (which means we should probably also limit its height and make sure the image doesn’t overflow or get distorted):

.grid--masonry > :first-child {   grid-column: 1/ -1;   max-height: 29vh; }  img {   max-height: inherit;   object-fit: cover;   /* same styles as before */ }

We also need to exclude this stretched item by adding another filter criterion when we get the list of grid items:

grids = grids.map(grid => ({   _el: grid,    gap: parseFloat(getComputedStyle(grid).gridRowGap),    items: [...grid.childNodes].filter(c =>      c.nodeType === 1 &&      +getComputedStyle(c).gridColumnEnd !== -1   ),    ncol: 0 }));

Handling grid items with variable aspect ratios

Let’s say we want to use this solution for something like a blog. We keep the exact same JS and almost the exact same masonry-specific CSS – we only change the maximum width a column may have and drop the max-height restriction for the first item.

As it can be seen from the demo below, our solution also works perfectly in this case where we have a grid of blog posts:

You can also resize the viewport to see how it behaves in this case.

However, if we want the width of the columns to be somewhat flexible, for example, something like this:

$ w: minmax(Min(20em, 100%), 1fr)

Then we have a problem on resize:

The changing width of the grid items combined with the fact that the text content is different for each means that when a certain threshold is crossed, we may get a different number of text lines for a grid item (thus changing the height), but not for the others. And if the number of columns doesn’t change, then the vertical offsets don’t get recomputed and we end up with either overlaps or bigger gaps.

In order to fix this, we need to also recompute the offsets whenever at least one item’s height changes for the current grid. This means we need to also need to test if more than zero items of the current grid have changed their height. And then we need to reset this value at the end of the if block so that we don’t rearrange the items needlessly next time around.

if(grid.ncol !== ncol || grid.mod) {   /* same as before */   grid.mod = 0 }

Alright, but how do we change this grid.mod value? My first idea was to use a ResizeObserver:

if(grids.length && getComputedStyle(grids[0]).gridTemplateRows !== 'masonry') {   let o = new ResizeObserver(entries => {     entries.forEach(entry => {       grids.find(grid => grid._el === entry.target.parentElement).mod = 1     });   });      /* same as before */      addEventListener('load', e => {     /* same as before */     grids.forEach(grid => { grid.items.forEach(c => o.observe(c)) })   }, false) }

This does the job of rearranging the grid items when necessary even if the number of grid columns doesn’t change. But it also makes even having that if condition pointless!

This is because it changes grid.mod to 1 whenever the height or the width of at least one item changes. The height of an item changes due to the text reflow, caused by the width changing. But the change in width happens every time we resize the viewport and doesn’t necessarily trigger a change in height.

This is why I eventually decided on storing the previous item heights and checking whether they have changed on resize to determine whether grid.mod remains 0 or not:

function layout() {   grids.forEach(grid => {     grid.items.forEach(c => {       let new_h = c.getBoundingClientRect().height; 				       if(new_h !== +c.dataset.h) {         c.dataset.h = new_h;         grid.mod++       }     }); 			     /* same as before */   }) }

That’s it! We now have a nice lightweight solution. The minified JavaScript is under 800 bytes, while the strictly masonry-related styles are under 300 bytes.

But, but, but…

What about browser support?

Well, @supports just so happens to have better browser support than any of the newer CSS features used here, so we can put the nice stuff inside it and have a basic, non-masonry grid for non-supporting browsers. This version works all the way back to IE9.

Screenshot showing the IE grid.
The result in Internet Explorer

It may not look the same, but it looks decent and it’s perfectly functional. Supporting a browser doesn’t mean replicating all the visual candy for it. It means the page works and doesn’t look broken or horrible.

What about the no JavaScript case?

Well, we can apply the fancy styles only if the root element has a js class which we add via JavaScript! Otherwise, we get a basic grid where all the items have the same size.

Screenshot showing the no JS grid.
The no JavaScript result (Demo).

The post A Lightweight Masonry Solution appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, ,

Block Links: The Search for a Perfect Solution

I was reading this article by Chris where he talks about block links — you know, like wrapping an entire card element inside an anchor — being a bad idea. It’s bad accessibility because of how it affects screen readers. And it’s bad UX because it prevents simple user tasks, like selecting text.

But maybe there’s something else at play. Maybe it’s less an issue with the pattern than the implementation of it. That led me to believe that this is the time to write follow-up article to see if we can address some of the problems Chris pointed out.

Throughout this post, I’ll use the term “card” to describe a component using the block link pattern. Here’s what we mean by that.

Let’s see how we want our Card Components to work:

  1. The whole thing should be linked and clickable.
  2. It should be able to contain more than one link.
  3. Content should be semantic so assistive tech can understand it.
  4. The text should be selectable, like regular links.
  5. Things like right-click and keyboard shortcuts should work with it
  6. Its elements should be focusable when tabbing.

That’s a long list! And since we don’t have any standard card widget provided by the browser, we don’t have any standard guidelines to build it. 

Like most things on the web, there’s more than one way to make a card component. However, I haven’t found something that checks all the requirements we just covered. In this article, we will try to hit all of them. That’s what we’re going to do now!

Method 1: Wrap everything an <a>

This is the most common and the easiest way to make a linked card. Take the HTML for the card and wrap the entire thing in an anchor tag.

<a href="/">   <!-- Card markup --> </a>

Here’s what that gives us:

  1. It’s clickable.
  2. It works with right-click and keyboard shortcuts.

Well, not great. We still can’t:

  1. Put another link inside the card because the entire thing is a single link
  2. Use it with a screen reader — the content is not semantic, so assistive technology will announce everything inside the card, starting from the time stamp
  3. Select text

That’s enough 👎 that we probably shouldn’t use it. Let’s move onto the next technique.

Method 2: Just link what needs linking

This is a nice compromise that sacrifices a little UX for improved accessibility.

With this pattern we achieve most of our goals:

  1. We can put as many links as we want. 
  2. Content is semantic.
  3. We can select the text from Card.
  4. Right Click and keyboard shortcuts work.
  5. The focus is in proper order when tabbing.

But it is missing the main feature we want in a card: the whole thing should be clickable! Looks like we need to try some other way.

Method 3: The good ol’  ::before pseudo element

In this one, we add a ::before or ::after element, place it above the card with absolute positioning and stretch it over the entire width and height of the card so it’s clickable.

But now:

  1. We still can’t add more than one link because anything else that’s linked is under the pseudo element layer. We can try to put all the text above the pseudo element, but card link itself won’t work when clicking on top of the text.
  2. We still can’t select the text. Again, we could swap layers, but then we’re back to the clickable link issue all over again.

Let’s try to actually check all the boxes here in our final technique.

Method 4: Sprinkle JavaScript on the second method

Let’s build off the second method. Recall that’s what where we link up everything we want to be a link:

<article class="card">   <time datetime="2020-03-20">Mar 20, 2020</time>   <h2><a href="https://css-tricks.com/a-complete-guide-to-calc-in-css/" class="main-link">A Complete Guide to calc() in CSS</a></h2>   <p>     In this guide, let’s cover just about everything there is to know about this very useful function.   </p>   <a class="author-name" href="https://css-tricks.com/author/chriscoyier/" target="_blank">Chris Coyier</a>     <div class="tags">       <a class="tag" href="https://css-tricks.com/tag/calc/" >calc</a>     </div> </article>

So how do we make the whole card clickable? We could use JavaScript as a progressive enhancement to do that. We’ll start by adding a click event listener to the card and trigger the click on the main link when it is triggered.

const card = document.querySelector(".card") const mainLink = document.querySelector('.main-link') 
 card.addEventListener("click", handleClick) 
 function handleClick(event){   mainLink.click(); }

Temporarily, this introduces the problem that we can’t select the text, which we’ve been trying to fix this whole time. Here’s the trick: we’ll use the relatively less-known web API window.getSelection. From MDN:

The Window.getSelection() method returns a Selection object representing the range of text selected by the user or the current position of the caret.

Although, this method returns an Object, we can convert it to a string with toString().

const isTextSelected = window.getSelection().toString()

With one line and no complicated kung-fu tricks with event listeners, we know if the user has selected text. Let’s use that in our handleClick function.

const card = document.querySelector(".card") const mainLink = document.querySelector('.main-link') 
 card.addEventListener("click", handleClick) 
 function handleClick(event){   const isTextSelected = window.getSelection().toString();   if (!isTextSelected) {     mainLink.click();   } }

This way, the main link can be clicked when no text selected, and all it took was a few lines of JavaScript. This satisfies our requirements:

  1. The whole thing is linked and clickable.
  2. It is able to contain more than one link.
  3. This content is semantic so assistive tech can understand it.
  4. The text should be selectable, like regular links.
  5. Things like right-click and keyboard shortcuts should work with it
  6. Its elements should be focusable when tabbing.

Here’s the final demo with all the JavaScript code we have added:

I think we’ve done it! Now you know how to make a perfect clickable card component.

What about other patterns? For example, what if the card contains the excerpt of a blog post followed by a “Read More’ link? Where should that go? Does that become the “main” link? What about image?

For those questions and more, here’s some further reading on the topic:

The post Block Links: The Search for a Perfect Solution appeared first on CSS-Tricks.

CSS-Tricks

, , , ,
[Top]

Fixed Headers and Jump Links? The Solution is scroll-margin-top

The problem: you click a jump link like <a href="#header-3">Jump</a> which links to something like <h3 id="header-3">Header</h3>. That’s totally fine, until you have a position: fixed; header at the top of the page obscuring the header you’re trying to link to!

Fixed headers have a nasty habit of hiding the element you’re trying to link to.

There used to be all kinds of wild hacks to get around this problem. In fact, in the design of CSS-Tricks as I write, I was like, “Screw it, I’ll just have a big generous padding-top on my in-article headers because I don’t mind that look anyway.”

But there is actually a really straightforward way of handling this in CSS now.

h3 {   scroll-margin-top: 5rem; /* whatever is a nice number that gets you past the header */ }

We have an Almanac article on it, which includes browser support, which is essentially everywhere. It’s often talked about in conjunction with scroll snapping, but I find this use case even more practical.

Here’s a simple demo:

In a related vein, that weird (but cool) “text fragments” link that Chrome shipped takes you to the middle of the page instead, which I think is nice.

The post Fixed Headers and Jump Links? The Solution is scroll-margin-top appeared first on CSS-Tricks.

CSS-Tricks

, , , , ,
[Top]